Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
6
src/components/forms/apply-steps/index.ts
Normal file
6
src/components/forms/apply-steps/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { StepWelcome } from './step-welcome'
|
||||
export { StepContact } from './step-contact'
|
||||
export { StepProject } from './step-project'
|
||||
export { StepTeam } from './step-team'
|
||||
export { StepAdditional } from './step-additional'
|
||||
export { StepReview } from './step-review'
|
||||
138
src/components/forms/apply-steps/step-additional.tsx
Normal file
138
src/components/forms/apply-steps/step-additional.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { Calendar, GraduationCap, Heart } from 'lucide-react'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
|
||||
interface StepAdditionalProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
isBusinessConcept: boolean
|
||||
isStartup: boolean
|
||||
}
|
||||
|
||||
export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAdditionalProps) {
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const wantsMentorship = watch('wantsMentorship')
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="A few more details"
|
||||
description="Help us understand your background and needs."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-md space-y-8"
|
||||
>
|
||||
{/* Institution (for Business Concepts) */}
|
||||
{isBusinessConcept && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-5 w-5 text-muted-foreground" />
|
||||
<Label htmlFor="institution">
|
||||
University/School <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="institution"
|
||||
placeholder="MIT, Stanford, INSEAD..."
|
||||
{...register('institution')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.institution && (
|
||||
<p className="text-sm text-destructive">{errors.institution.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the name of your university or educational institution.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Startup Created Date (for Startups) */}
|
||||
{isStartup && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground" />
|
||||
<Label htmlFor="startupCreatedDate">
|
||||
When was your startup created? <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="startupCreatedDate"
|
||||
type="date"
|
||||
{...register('startupCreatedDate')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.startupCreatedDate && (
|
||||
<p className="text-sm text-destructive">{errors.startupCreatedDate.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the date your startup was officially registered or founded.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Mentorship */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Heart className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="wantsMentorship" className="text-base font-medium">
|
||||
Would you like mentorship support?
|
||||
</Label>
|
||||
<Switch
|
||||
id="wantsMentorship"
|
||||
checked={wantsMentorship}
|
||||
onCheckedChange={(checked) => setValue('wantsMentorship', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Our mentors are industry experts who can help guide your project.
|
||||
This is optional but highly recommended.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Referral Source */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label htmlFor="referralSource">
|
||||
How did you hear about us? <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="referralSource"
|
||||
placeholder="Friend, Social media, Event..."
|
||||
{...register('referralSource')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
111
src/components/forms/apply-steps/step-contact.tsx
Normal file
111
src/components/forms/apply-steps/step-contact.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { PhoneInput } from '@/components/ui/phone-input'
|
||||
import { CountrySelect } from '@/components/ui/country-select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
|
||||
interface StepContactProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
}
|
||||
|
||||
export function StepContact({ form }: StepContactProps) {
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const country = watch('country')
|
||||
const phone = watch('contactPhone')
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Tell us about yourself"
|
||||
description="We'll use this information to contact you about your application."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-md space-y-6"
|
||||
>
|
||||
{/* Full Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactName">
|
||||
Full Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="contactName"
|
||||
placeholder="John Smith"
|
||||
{...register('contactName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.contactName && (
|
||||
<p className="text-sm text-destructive">{errors.contactName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactEmail">
|
||||
Email Address <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="contactEmail"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
{...register('contactEmail')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.contactEmail && (
|
||||
<p className="text-sm text-destructive">{errors.contactEmail.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPhone">
|
||||
Phone Number <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<PhoneInput
|
||||
value={phone}
|
||||
onChange={(value) => setValue('contactPhone', value || '')}
|
||||
defaultCountry="MC"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.contactPhone && (
|
||||
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Country <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<CountrySelect
|
||||
value={country}
|
||||
onChange={(value) => setValue('country', value)}
|
||||
placeholder="Select your country"
|
||||
className="h-12"
|
||||
/>
|
||||
{errors.country && (
|
||||
<p className="text-sm text-destructive">{errors.country.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* City (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">
|
||||
City <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
placeholder="Monaco"
|
||||
{...register('city')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
141
src/components/forms/apply-steps/step-project.tsx
Normal file
141
src/components/forms/apply-steps/step-project.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { OceanIssue } from '@prisma/client'
|
||||
|
||||
interface OceanIssueOption {
|
||||
value: OceanIssue
|
||||
label: string
|
||||
}
|
||||
|
||||
const oceanIssueOptions: OceanIssueOption[] = [
|
||||
{ value: 'POLLUTION_REDUCTION', label: 'Reduction of pollution (plastics, chemicals, noise, light,...)' },
|
||||
{ value: 'CLIMATE_MITIGATION', label: 'Mitigation of climate change and sea-level rise' },
|
||||
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology & innovations' },
|
||||
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable shipping & yachting' },
|
||||
{ value: 'BLUE_CARBON', label: 'Blue carbon' },
|
||||
{ value: 'HABITAT_RESTORATION', label: 'Restoration of marine habitats & ecosystems' },
|
||||
{ value: 'COMMUNITY_CAPACITY', label: 'Capacity building for coastal communities' },
|
||||
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable fishing and aquaculture & blue food' },
|
||||
{ value: 'CONSUMER_AWARENESS', label: 'Consumer awareness and education' },
|
||||
{ value: 'OCEAN_ACIDIFICATION', label: 'Mitigation of ocean acidification' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
]
|
||||
|
||||
interface StepProjectProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
}
|
||||
|
||||
export function StepProject({ form }: StepProjectProps) {
|
||||
const { register, formState: { errors }, setValue, watch } = form
|
||||
const oceanIssue = watch('oceanIssue')
|
||||
const description = watch('description') || ''
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Tell us about your project"
|
||||
description="Share the details of your ocean protection initiative."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-lg space-y-6"
|
||||
>
|
||||
{/* Project Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectName">
|
||||
Name of your project/startup <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="projectName"
|
||||
placeholder="Ocean Guardian AI"
|
||||
{...register('projectName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
{errors.projectName && (
|
||||
<p className="text-sm text-destructive">{errors.projectName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Name (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="teamName">
|
||||
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="teamName"
|
||||
placeholder="Blue Innovation Team"
|
||||
{...register('teamName')}
|
||||
className="h-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ocean Issue */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
What type of ocean issue does your project address? <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={oceanIssue}
|
||||
onValueChange={(value) => setValue('oceanIssue', value as OceanIssue)}
|
||||
>
|
||||
<SelectTrigger className="h-12 text-base">
|
||||
<SelectValue placeholder="Select an ocean issue" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{oceanIssueOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.oceanIssue && (
|
||||
<p className="text-sm text-destructive">{errors.oceanIssue.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
Briefly describe your project idea and objectives <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keep it brief - you'll have the opportunity to provide more details later.
|
||||
</p>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Our project aims to..."
|
||||
rows={5}
|
||||
maxLength={2000}
|
||||
{...register('description')}
|
||||
className="text-base resize-none"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{errors.description ? (
|
||||
<span className="text-destructive">{errors.description.message}</span>
|
||||
) : (
|
||||
'Minimum 20 characters'
|
||||
)}
|
||||
</span>
|
||||
<span>{description.length} characters</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
213
src/components/forms/apply-steps/step-review.tsx
Normal file
213
src/components/forms/apply-steps/step-review.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Briefcase,
|
||||
Waves,
|
||||
Users,
|
||||
GraduationCap,
|
||||
Calendar,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { countries } from '@/components/ui/country-select'
|
||||
|
||||
const oceanIssueLabels: Record<string, string> = {
|
||||
POLLUTION_REDUCTION: 'Reduction of pollution',
|
||||
CLIMATE_MITIGATION: 'Climate change mitigation',
|
||||
TECHNOLOGY_INNOVATION: 'Technology & innovations',
|
||||
SUSTAINABLE_SHIPPING: 'Sustainable shipping & yachting',
|
||||
BLUE_CARBON: 'Blue carbon',
|
||||
HABITAT_RESTORATION: 'Marine habitat restoration',
|
||||
COMMUNITY_CAPACITY: 'Coastal community capacity',
|
||||
SUSTAINABLE_FISHING: 'Sustainable fishing & aquaculture',
|
||||
CONSUMER_AWARENESS: 'Consumer awareness & education',
|
||||
OCEAN_ACIDIFICATION: 'Ocean acidification mitigation',
|
||||
OTHER: 'Other',
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
BUSINESS_CONCEPT: 'Business Concepts',
|
||||
STARTUP: 'Start-ups',
|
||||
}
|
||||
|
||||
interface StepReviewProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
programName: string
|
||||
}
|
||||
|
||||
export function StepReview({ form, programName }: StepReviewProps) {
|
||||
const { formState: { errors }, setValue, watch } = form
|
||||
const data = watch()
|
||||
|
||||
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Review your application"
|
||||
description="Please review your information before submitting."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-lg space-y-6"
|
||||
>
|
||||
{/* Contact Info Section */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Contact Information
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.contactName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.contactEmail}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.contactPhone}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.city ? `${data.city}, ${countryName}` : countryName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Info Section */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
Project Details
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Project Name:</span>
|
||||
<p className="font-medium">{data.projectName}</p>
|
||||
</div>
|
||||
{data.teamName && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Team Name:</span>
|
||||
<p className="font-medium">{data.teamName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{categoryLabels[data.competitionCategory]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Waves className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{oceanIssueLabels[data.oceanIssue]}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Description:</span>
|
||||
<p className="mt-1 text-sm text-foreground/80 whitespace-pre-wrap">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Members Section */}
|
||||
{data.teamMembers && data.teamMembers.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Team Members ({data.teamMembers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data.teamMembers.map((member, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{member.name}</span>
|
||||
<span className="text-muted-foreground"> - {member.email}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.role === 'MEMBER' ? 'Member' : 'Advisor'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Info Section */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="mb-4 font-semibold flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Additional Information
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{data.institution && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{data.institution}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.startupCreatedDate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Founded: {new Date(data.startupCreatedDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className={`h-4 w-4 ${data.wantsMentorship ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<span>
|
||||
{data.wantsMentorship
|
||||
? 'Interested in mentorship'
|
||||
: 'Not interested in mentorship'}
|
||||
</span>
|
||||
</div>
|
||||
{data.referralSource && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Heard about us via: {data.referralSource}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GDPR Consent */}
|
||||
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="gdprConsent"
|
||||
checked={data.gdprConsent}
|
||||
onCheckedChange={(checked) => setValue('gdprConsent', checked === true)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="gdprConsent" className="text-sm font-medium">
|
||||
I consent to the processing of my personal data <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
By submitting this application, I agree that {programName} may process my personal
|
||||
data in accordance with their privacy policy. My data will be used solely for the
|
||||
purpose of evaluating my application and communicating with me about the program.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{errors.gdprConsent && (
|
||||
<p className="mt-2 text-sm text-destructive">{errors.gdprConsent.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
184
src/components/forms/apply-steps/step-team.tsx
Normal file
184
src/components/forms/apply-steps/step-team.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { UseFormReturn, useFieldArray } from 'react-hook-form'
|
||||
import { Plus, Trash2, Users } from 'lucide-react'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ApplicationFormData } from '@/server/routers/application'
|
||||
import { TeamMemberRole } from '@prisma/client'
|
||||
|
||||
const roleOptions: { value: TeamMemberRole; label: string }[] = [
|
||||
{ value: 'MEMBER', label: 'Team Member' },
|
||||
{ value: 'ADVISOR', label: 'Advisor' },
|
||||
]
|
||||
|
||||
interface StepTeamProps {
|
||||
form: UseFormReturn<ApplicationFormData>
|
||||
}
|
||||
|
||||
export function StepTeam({ form }: StepTeamProps) {
|
||||
const { control, register, formState: { errors } } = form
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'teamMembers',
|
||||
})
|
||||
|
||||
const addMember = () => {
|
||||
append({ name: '', email: '', role: 'MEMBER', title: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardStepContent
|
||||
title="Your team members"
|
||||
description="Add the other members of your team. They will receive an invitation to create their account."
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto max-w-lg space-y-6"
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 py-12 text-center">
|
||||
<Users className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">
|
||||
No team members added yet.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
You can add team members here, or skip this step if you're applying solo.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addMember}
|
||||
className="mt-4"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Team Member
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{fields.map((field, index) => (
|
||||
<motion.div
|
||||
key={field.id}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h4 className="font-medium text-sm text-muted-foreground">
|
||||
Team Member {index + 1}
|
||||
</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`teamMembers.${index}.name`}>
|
||||
Full Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`teamMembers.${index}.name`}
|
||||
placeholder="Jane Doe"
|
||||
{...register(`teamMembers.${index}.name`)}
|
||||
/>
|
||||
{errors.teamMembers?.[index]?.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.teamMembers[index]?.name?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`teamMembers.${index}.email`}>
|
||||
Email <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`teamMembers.${index}.email`}
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
{...register(`teamMembers.${index}.email`)}
|
||||
/>
|
||||
{errors.teamMembers?.[index]?.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.teamMembers[index]?.email?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={form.watch(`teamMembers.${index}.role`)}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(`teamMembers.${index}.role`, value as TeamMemberRole)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Title/Position */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`teamMembers.${index}.title`}>
|
||||
Title/Position <span className="text-muted-foreground text-xs">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`teamMembers.${index}.title`}
|
||||
placeholder="CTO, Designer, etc."
|
||||
{...register(`teamMembers.${index}.title`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addMember}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Another Team Member
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
132
src/components/forms/apply-steps/step-welcome.tsx
Normal file
132
src/components/forms/apply-steps/step-welcome.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Waves, Rocket, GraduationCap } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
||||
import { CompetitionCategory } from '@prisma/client'
|
||||
|
||||
interface CategoryOption {
|
||||
value: CompetitionCategory
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Rocket
|
||||
}
|
||||
|
||||
const categories: CategoryOption[] = [
|
||||
{
|
||||
value: 'BUSINESS_CONCEPT',
|
||||
label: 'Business Concepts',
|
||||
description: 'For students and recent graduates with innovative ocean-focused business ideas',
|
||||
icon: GraduationCap,
|
||||
},
|
||||
{
|
||||
value: 'STARTUP',
|
||||
label: 'Start-ups',
|
||||
description: 'For established companies working on ocean protection solutions',
|
||||
icon: Rocket,
|
||||
},
|
||||
]
|
||||
|
||||
interface StepWelcomeProps {
|
||||
programName: string
|
||||
programYear: number
|
||||
value: CompetitionCategory | null
|
||||
onChange: (value: CompetitionCategory) => void
|
||||
}
|
||||
|
||||
export function StepWelcome({ programName, programYear, value, onChange }: StepWelcomeProps) {
|
||||
return (
|
||||
<WizardStepContent>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Logo/Icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', duration: 0.8 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
|
||||
<Waves className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Welcome text */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
|
||||
{programName}
|
||||
</h1>
|
||||
<p className="mt-2 text-xl text-primary font-semibold">
|
||||
{programYear} Application
|
||||
</p>
|
||||
<p className="mt-4 max-w-md text-muted-foreground">
|
||||
Join us in protecting our oceans. Select your category to begin.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Category selection */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
|
||||
>
|
||||
{categories.map((category) => {
|
||||
const Icon = category.icon
|
||||
const isSelected = value === category.value
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.value}
|
||||
type="button"
|
||||
onClick={() => onChange(category.value)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:shadow-md',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 shadow-md'
|
||||
: 'border-border bg-background'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mb-4 flex h-14 w-14 items-center justify-center rounded-full transition-colors',
|
||||
isSelected ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{category.label}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{category.description}
|
||||
</p>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
layoutId="selected-indicator"
|
||||
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</WizardStepContent>
|
||||
)
|
||||
}
|
||||
627
src/components/forms/csv-import-form.tsx
Normal file
627
src/components/forms/csv-import-form.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Papa from 'papaparse'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Upload,
|
||||
FileSpreadsheet,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CSVImportFormProps {
|
||||
roundId: string
|
||||
roundName: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
type Step = 'upload' | 'mapping' | 'validation' | 'importing' | 'complete'
|
||||
|
||||
// Required and optional fields for project import
|
||||
const PROJECT_FIELDS = [
|
||||
{ key: 'title', label: 'Title', required: true },
|
||||
{ key: 'teamName', label: 'Team Name', required: false },
|
||||
{ key: 'description', label: 'Description', required: false },
|
||||
{ key: 'tags', label: 'Tags (comma-separated)', required: false },
|
||||
]
|
||||
|
||||
interface ParsedRow {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
interface ValidationError {
|
||||
row: number
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface MappedProject {
|
||||
title: string
|
||||
teamName?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
metadataJson?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function CSVImportForm({ roundId, roundName, onSuccess }: CSVImportFormProps) {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>('upload')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [csvData, setCsvData] = useState<ParsedRow[]>([])
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
|
||||
const [columnMapping, setColumnMapping] = useState<Record<string, string>>({})
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([])
|
||||
const [importProgress, setImportProgress] = useState(0)
|
||||
|
||||
const importMutation = trpc.project.importCSV.useMutation({
|
||||
onSuccess: () => {
|
||||
setStep('complete')
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
// Handle file selection and parsing
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (!selectedFile) return
|
||||
|
||||
setFile(selectedFile)
|
||||
|
||||
Papa.parse<ParsedRow>(selectedFile, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (results.data.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const headers = results.meta.fields || []
|
||||
setCsvHeaders(headers)
|
||||
setCsvData(results.data)
|
||||
|
||||
// Auto-map columns with exact or similar names
|
||||
const autoMapping: Record<string, string> = {}
|
||||
PROJECT_FIELDS.forEach((field) => {
|
||||
const matchingHeader = headers.find(
|
||||
(h) =>
|
||||
h.toLowerCase() === field.key.toLowerCase() ||
|
||||
h.toLowerCase().replace(/[_\s-]/g, '') ===
|
||||
field.key.toLowerCase().replace(/[_\s-]/g, '') ||
|
||||
h.toLowerCase().includes(field.key.toLowerCase())
|
||||
)
|
||||
if (matchingHeader) {
|
||||
autoMapping[field.key] = matchingHeader
|
||||
}
|
||||
})
|
||||
setColumnMapping(autoMapping)
|
||||
|
||||
setStep('mapping')
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('CSV parse error:', error)
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Handle column mapping change
|
||||
const handleMappingChange = (fieldKey: string, csvColumn: string) => {
|
||||
setColumnMapping((prev) => ({
|
||||
...prev,
|
||||
[fieldKey]: csvColumn === '__none__' ? '' : csvColumn,
|
||||
}))
|
||||
}
|
||||
|
||||
// Validate mapped data
|
||||
const validateData = useCallback((): {
|
||||
valid: MappedProject[]
|
||||
errors: ValidationError[]
|
||||
} => {
|
||||
const errors: ValidationError[] = []
|
||||
const valid: MappedProject[] = []
|
||||
|
||||
csvData.forEach((row, index) => {
|
||||
const rowNum = index + 2 // +2 for header row and 0-indexing
|
||||
|
||||
// Check required fields
|
||||
const titleColumn = columnMapping.title
|
||||
const title = titleColumn ? row[titleColumn]?.trim() : ''
|
||||
|
||||
if (!title) {
|
||||
errors.push({
|
||||
row: rowNum,
|
||||
field: 'title',
|
||||
message: 'Title is required',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build mapped project
|
||||
const project: MappedProject = {
|
||||
title,
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
const teamNameColumn = columnMapping.teamName
|
||||
if (teamNameColumn && row[teamNameColumn]) {
|
||||
project.teamName = row[teamNameColumn].trim()
|
||||
}
|
||||
|
||||
const descriptionColumn = columnMapping.description
|
||||
if (descriptionColumn && row[descriptionColumn]) {
|
||||
project.description = row[descriptionColumn].trim()
|
||||
}
|
||||
|
||||
const tagsColumn = columnMapping.tags
|
||||
if (tagsColumn && row[tagsColumn]) {
|
||||
project.tags = row[tagsColumn]
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Store unmapped columns as metadata
|
||||
const mappedColumns = new Set(Object.values(columnMapping).filter(Boolean))
|
||||
const unmappedData: Record<string, unknown> = {}
|
||||
Object.entries(row).forEach(([key, value]) => {
|
||||
if (!mappedColumns.has(key) && value?.trim()) {
|
||||
unmappedData[key] = value.trim()
|
||||
}
|
||||
})
|
||||
if (Object.keys(unmappedData).length > 0) {
|
||||
project.metadataJson = unmappedData
|
||||
}
|
||||
|
||||
valid.push(project)
|
||||
})
|
||||
|
||||
return { valid, errors }
|
||||
}, [csvData, columnMapping])
|
||||
|
||||
// Proceed to validation step
|
||||
const handleProceedToValidation = () => {
|
||||
const { valid, errors } = validateData()
|
||||
setValidationErrors(errors)
|
||||
setStep('validation')
|
||||
}
|
||||
|
||||
// Start import
|
||||
const handleStartImport = async () => {
|
||||
const { valid } = validateData()
|
||||
|
||||
if (valid.length === 0) return
|
||||
|
||||
setStep('importing')
|
||||
setImportProgress(0)
|
||||
|
||||
try {
|
||||
await importMutation.mutateAsync({
|
||||
roundId,
|
||||
projects: valid,
|
||||
})
|
||||
setImportProgress(100)
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error)
|
||||
setStep('validation')
|
||||
}
|
||||
}
|
||||
|
||||
// Validation summary
|
||||
const validationSummary = useMemo(() => {
|
||||
const { valid, errors } = validateData()
|
||||
return {
|
||||
total: csvData.length,
|
||||
valid: valid.length,
|
||||
errors: errors.length,
|
||||
errorRows: [...new Set(errors.map((e) => e.row))].length,
|
||||
}
|
||||
}, [csvData, validateData])
|
||||
|
||||
// Render step content
|
||||
const renderStep = () => {
|
||||
switch (step) {
|
||||
case 'upload':
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upload CSV File</CardTitle>
|
||||
<CardDescription>
|
||||
Upload a CSV file containing project data to import into{' '}
|
||||
<strong>{roundName}</strong>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-8 text-center transition-colors',
|
||||
'hover:border-primary/50 cursor-pointer'
|
||||
)}
|
||||
onClick={() => document.getElementById('csv-file')?.click()}
|
||||
>
|
||||
<FileSpreadsheet className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-2 font-medium">
|
||||
Drop your CSV file here or click to browse
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Supports .csv files only
|
||||
</p>
|
||||
<Input
|
||||
id="csv-file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<p className="text-sm font-medium mb-2">Expected columns:</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
{PROJECT_FIELDS.map((field) => (
|
||||
<li key={field.key}>
|
||||
<strong>{field.label}</strong>
|
||||
{field.required && (
|
||||
<Badge variant="destructive" className="ml-2 text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
case 'mapping':
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Map Columns</CardTitle>
|
||||
<CardDescription>
|
||||
Map your CSV columns to project fields. {csvData.length} rows
|
||||
found.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* File info */}
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted p-3">
|
||||
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{file?.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{csvData.length} rows, {csvHeaders.length} columns
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
setFile(null)
|
||||
setCsvData([])
|
||||
setCsvHeaders([])
|
||||
setColumnMapping({})
|
||||
setStep('upload')
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Column mapping */}
|
||||
<div className="space-y-4">
|
||||
{PROJECT_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4"
|
||||
>
|
||||
<div className="sm:w-48 flex items-center gap-2">
|
||||
<Label className="font-medium">{field.label}</Label>
|
||||
{field.required && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={columnMapping[field.key] || '__none__'}
|
||||
onValueChange={(v) => handleMappingChange(field.key, v)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select column" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
<span className="text-muted-foreground">
|
||||
-- Not mapped --
|
||||
</span>
|
||||
</SelectItem>
|
||||
{csvHeaders.map((header) => (
|
||||
<SelectItem key={header} value={header}>
|
||||
{header}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">Preview (first 5 rows)</Label>
|
||||
<div className="rounded-lg border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{PROJECT_FIELDS.filter(
|
||||
(f) => columnMapping[f.key]
|
||||
).map((field) => (
|
||||
<TableHead key={field.key}>{field.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{csvData.slice(0, 5).map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
{PROJECT_FIELDS.filter(
|
||||
(f) => columnMapping[f.key]
|
||||
).map((field) => (
|
||||
<TableCell key={field.key} className="max-w-[200px] truncate">
|
||||
{row[columnMapping[field.key]] || '-'}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFile(null)
|
||||
setCsvData([])
|
||||
setCsvHeaders([])
|
||||
setColumnMapping({})
|
||||
setStep('upload')
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleProceedToValidation}
|
||||
disabled={!columnMapping.title}
|
||||
>
|
||||
Validate Data
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
case 'validation':
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Validation Summary</CardTitle>
|
||||
<CardDescription>
|
||||
Review the validation results before importing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-lg bg-muted p-4 text-center">
|
||||
<p className="text-3xl font-bold">{validationSummary.total}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Rows</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-green-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{validationSummary.valid}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Valid Projects</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-red-500/10 p-4 text-center">
|
||||
<p className="text-3xl font-bold text-red-600">
|
||||
{validationSummary.errorRows}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Rows with Errors</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors list */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium text-destructive">
|
||||
Validation Errors
|
||||
</Label>
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/5 p-4 max-h-60 overflow-y-auto">
|
||||
{validationErrors.slice(0, 20).map((error, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 text-sm py-1"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Row {error.row}:</strong> {error.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{validationErrors.length > 20 && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
... and {validationErrors.length - 20} more errors
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{validationSummary.valid > 0 && validationErrors.length === 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-green-500/10 p-4 text-green-700">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>All {validationSummary.valid} projects are valid and ready to import!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={() => setStep('mapping')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Mapping
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStartImport}
|
||||
disabled={validationSummary.valid === 0 || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Import {validationSummary.valid} Projects
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Import error */}
|
||||
{importMutation.error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-4 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>
|
||||
Import failed: {importMutation.error.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
case 'importing':
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 font-medium">Importing projects...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please wait while we process your data
|
||||
</p>
|
||||
<Progress value={importProgress} className="mt-4 w-48" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
case 'complete':
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<p className="mt-4 text-xl font-semibold">Import Complete!</p>
|
||||
<p className="text-muted-foreground">
|
||||
Successfully imported {validationSummary.valid} projects into{' '}
|
||||
<strong>{roundName}</strong>
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
Back to Projects
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFile(null)
|
||||
setCsvData([])
|
||||
setCsvHeaders([])
|
||||
setColumnMapping({})
|
||||
setValidationErrors([])
|
||||
setStep('upload')
|
||||
}}
|
||||
>
|
||||
Import More
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{(['upload', 'mapping', 'validation', 'complete'] as const).map(
|
||||
(s, index) => (
|
||||
<div key={s} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-8 mx-1',
|
||||
step === s ||
|
||||
['upload', 'mapping', 'validation', 'complete'].indexOf(
|
||||
step
|
||||
) >
|
||||
['upload', 'mapping', 'validation', 'complete'].indexOf(s)
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium',
|
||||
step === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: ['upload', 'mapping', 'validation', 'complete'].indexOf(
|
||||
step
|
||||
) > ['upload', 'mapping', 'validation', 'complete'].indexOf(s)
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderStep()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
516
src/components/forms/evaluation-form-builder.tsx
Normal file
516
src/components/forms/evaluation-form-builder.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Edit,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number // 5 or 10
|
||||
weight?: number
|
||||
required: boolean
|
||||
}
|
||||
|
||||
interface EvaluationFormBuilderProps {
|
||||
initialCriteria?: Criterion[]
|
||||
onChange: (criteria: Criterion[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
function createDefaultCriterion(): Criterion {
|
||||
return {
|
||||
id: generateId(),
|
||||
label: '',
|
||||
description: '',
|
||||
scale: 5,
|
||||
weight: 1,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function EvaluationFormBuilder({
|
||||
initialCriteria = [],
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: EvaluationFormBuilderProps) {
|
||||
const [criteria, setCriteria] = useState<Criterion[]>(initialCriteria)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editDraft, setEditDraft] = useState<Criterion | null>(null)
|
||||
|
||||
// Update parent when criteria change
|
||||
const updateCriteria = useCallback(
|
||||
(newCriteria: Criterion[]) => {
|
||||
setCriteria(newCriteria)
|
||||
onChange(newCriteria)
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
// Add new criterion
|
||||
const addCriterion = useCallback(() => {
|
||||
const newCriterion = createDefaultCriterion()
|
||||
const newCriteria = [...criteria, newCriterion]
|
||||
updateCriteria(newCriteria)
|
||||
setEditingId(newCriterion.id)
|
||||
setEditDraft(newCriterion)
|
||||
}, [criteria, updateCriteria])
|
||||
|
||||
// Delete criterion
|
||||
const deleteCriterion = useCallback(
|
||||
(id: string) => {
|
||||
updateCriteria(criteria.filter((c) => c.id !== id))
|
||||
if (editingId === id) {
|
||||
setEditingId(null)
|
||||
setEditDraft(null)
|
||||
}
|
||||
},
|
||||
[criteria, editingId, updateCriteria]
|
||||
)
|
||||
|
||||
// Move criterion up/down
|
||||
const moveCriterion = useCallback(
|
||||
(id: string, direction: 'up' | 'down') => {
|
||||
const index = criteria.findIndex((c) => c.id === id)
|
||||
if (index === -1) return
|
||||
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1
|
||||
if (newIndex < 0 || newIndex >= criteria.length) return
|
||||
|
||||
const newCriteria = [...criteria]
|
||||
const [removed] = newCriteria.splice(index, 1)
|
||||
newCriteria.splice(newIndex, 0, removed)
|
||||
updateCriteria(newCriteria)
|
||||
},
|
||||
[criteria, updateCriteria]
|
||||
)
|
||||
|
||||
// Start editing
|
||||
const startEditing = useCallback((criterion: Criterion) => {
|
||||
setEditingId(criterion.id)
|
||||
setEditDraft({ ...criterion })
|
||||
}, [])
|
||||
|
||||
// Cancel editing
|
||||
const cancelEditing = useCallback(() => {
|
||||
// If it's a new criterion with no label, remove it
|
||||
if (editDraft && !editDraft.label.trim()) {
|
||||
updateCriteria(criteria.filter((c) => c.id !== editDraft.id))
|
||||
}
|
||||
setEditingId(null)
|
||||
setEditDraft(null)
|
||||
}, [editDraft, criteria, updateCriteria])
|
||||
|
||||
// Save editing
|
||||
const saveEditing = useCallback(() => {
|
||||
if (!editDraft || !editDraft.label.trim()) return
|
||||
|
||||
updateCriteria(
|
||||
criteria.map((c) => (c.id === editDraft.id ? editDraft : c))
|
||||
)
|
||||
setEditingId(null)
|
||||
setEditDraft(null)
|
||||
}, [editDraft, criteria, updateCriteria])
|
||||
|
||||
// Update edit draft
|
||||
const updateDraft = useCallback(
|
||||
(updates: Partial<Criterion>) => {
|
||||
if (!editDraft) return
|
||||
setEditDraft({ ...editDraft, ...updates })
|
||||
},
|
||||
[editDraft]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Criteria list */}
|
||||
{criteria.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{criteria.map((criterion, index) => {
|
||||
const isEditing = editingId === criterion.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={criterion.id}
|
||||
className={cn(
|
||||
'rounded-lg border transition-colors',
|
||||
isEditing ? 'border-primary bg-muted/30' : 'bg-background',
|
||||
disabled && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{isEditing && editDraft ? (
|
||||
// Edit mode
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`label-${criterion.id}`}>Label *</Label>
|
||||
<Input
|
||||
id={`label-${criterion.id}`}
|
||||
value={editDraft.label}
|
||||
onChange={(e) => updateDraft({ label: e.target.value })}
|
||||
placeholder="e.g., Innovation"
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`description-${criterion.id}`}>
|
||||
Description (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`description-${criterion.id}`}
|
||||
value={editDraft.description || ''}
|
||||
onChange={(e) => updateDraft({ description: e.target.value })}
|
||||
placeholder="Help jurors understand this criterion..."
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`scale-${criterion.id}`}>Scale</Label>
|
||||
<Select
|
||||
value={String(editDraft.scale)}
|
||||
onValueChange={(v) => updateDraft({ scale: parseInt(v) })}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id={`scale-${criterion.id}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">1-5</SelectItem>
|
||||
<SelectItem value="10">1-10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`weight-${criterion.id}`}>
|
||||
Weight (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id={`weight-${criterion.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={editDraft.weight ?? ''}
|
||||
onChange={(e) =>
|
||||
updateDraft({
|
||||
weight: e.target.value
|
||||
? parseFloat(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="1"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Required</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<Switch
|
||||
checked={editDraft.required}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft({ required: checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelEditing}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={saveEditing}
|
||||
disabled={disabled || !editDraft.label.trim()}
|
||||
>
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// View mode
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
{/* Drag handle / position indicator */}
|
||||
<div className="text-muted-foreground/50">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Criterion info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium truncate">
|
||||
{criterion.label || '(Untitled)'}
|
||||
</span>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
1-{criterion.scale}
|
||||
</Badge>
|
||||
{criterion.weight && criterion.weight !== 1 && (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{criterion.weight}x
|
||||
</Badge>
|
||||
)}
|
||||
{criterion.required && (
|
||||
<Badge variant="default" className="shrink-0 text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||
{criterion.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="Move criterion up"
|
||||
onClick={() => moveCriterion(criterion.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="Move criterion down"
|
||||
onClick={() => moveCriterion(criterion.id, 'down')}
|
||||
disabled={index === criteria.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="Edit criterion"
|
||||
onClick={() => startEditing(criterion)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
aria-label="Delete criterion"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete criterion?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{criterion.label}"?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteCriterion(criterion.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No evaluation criteria defined yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addCriterion}
|
||||
disabled={editingId !== null}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Criterion
|
||||
</Button>
|
||||
|
||||
{criteria.length > 0 && (
|
||||
<PreviewDialog criteria={criteria} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Preview dialog showing how the evaluation form will look
|
||||
function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm">
|
||||
<Eye className="mr-1 h-4 w-4" />
|
||||
Preview
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Evaluation Form Preview</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is how the evaluation form will appear to jurors.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{criteria.map((criterion) => (
|
||||
<Card key={criterion.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{criterion.label}
|
||||
{criterion.required && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{criterion.description && (
|
||||
<CardDescription>{criterion.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-4">1</span>
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary/30 rounded-full"
|
||||
style={{ width: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-4">
|
||||
{criterion.scale}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map(
|
||||
(num) => (
|
||||
<div
|
||||
key={num}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium flex items-center justify-center',
|
||||
num <= Math.ceil(criterion.scale / 2)
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{criteria.length === 0 && (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
No criteria to preview.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
600
src/components/forms/evaluation-form.tsx
Normal file
600
src/components/forms/evaluation-form.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Loader2,
|
||||
Save,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Star,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Define criterion type from the evaluation form JSON
|
||||
interface Criterion {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
scale: number // max value (e.g., 5 or 10)
|
||||
weight?: number
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
interface EvaluationFormProps {
|
||||
assignmentId: string
|
||||
evaluationId: string | null
|
||||
projectTitle: string
|
||||
criteria: Criterion[]
|
||||
initialData?: {
|
||||
criterionScoresJson: Record<string, number> | null
|
||||
globalScore: number | null
|
||||
binaryDecision: boolean | null
|
||||
feedbackText: string | null
|
||||
status: string
|
||||
}
|
||||
isVotingOpen: boolean
|
||||
deadline?: Date | null
|
||||
}
|
||||
|
||||
const createEvaluationSchema = (criteria: Criterion[]) =>
|
||||
z.object({
|
||||
criterionScores: z.record(z.number()),
|
||||
globalScore: z.number().int().min(1).max(10),
|
||||
binaryDecision: z.boolean(),
|
||||
feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'),
|
||||
})
|
||||
|
||||
type EvaluationFormData = z.infer<ReturnType<typeof createEvaluationSchema>>
|
||||
|
||||
export function EvaluationForm({
|
||||
assignmentId,
|
||||
evaluationId,
|
||||
projectTitle,
|
||||
criteria,
|
||||
initialData,
|
||||
isVotingOpen,
|
||||
deadline,
|
||||
}: EvaluationFormProps) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||
|
||||
// Initialize criterion scores with existing data or defaults
|
||||
const defaultCriterionScores: Record<string, number> = {}
|
||||
criteria.forEach((c) => {
|
||||
defaultCriterionScores[c.id] = initialData?.criterionScoresJson?.[c.id] ?? Math.ceil(c.scale / 2)
|
||||
})
|
||||
|
||||
const form = useForm<EvaluationFormData>({
|
||||
resolver: zodResolver(createEvaluationSchema(criteria)),
|
||||
defaultValues: {
|
||||
criterionScores: defaultCriterionScores,
|
||||
globalScore: initialData?.globalScore ?? 5,
|
||||
binaryDecision: initialData?.binaryDecision ?? false,
|
||||
feedbackText: initialData?.feedbackText ?? '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const { watch, handleSubmit, control, formState } = form
|
||||
const { errors, isValid, isDirty } = formState
|
||||
|
||||
// tRPC mutations
|
||||
const startEvaluation = trpc.evaluation.start.useMutation()
|
||||
const autosave = trpc.evaluation.autosave.useMutation()
|
||||
const submit = trpc.evaluation.submit.useMutation()
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// State to track the current evaluation ID (might be created on first autosave)
|
||||
const [currentEvaluationId, setCurrentEvaluationId] = useState<string | null>(evaluationId)
|
||||
|
||||
// Create evaluation if it doesn't exist
|
||||
useEffect(() => {
|
||||
if (!currentEvaluationId && isVotingOpen) {
|
||||
startEvaluation.mutate(
|
||||
{ assignmentId },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setCurrentEvaluationId(data.id)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [assignmentId, currentEvaluationId, isVotingOpen])
|
||||
|
||||
// Debounced autosave function
|
||||
const debouncedAutosave = useDebouncedCallback(
|
||||
async (data: EvaluationFormData) => {
|
||||
if (!currentEvaluationId || !isVotingOpen) return
|
||||
|
||||
setAutosaveStatus('saving')
|
||||
|
||||
try {
|
||||
await autosave.mutateAsync({
|
||||
id: currentEvaluationId,
|
||||
criterionScoresJson: data.criterionScores,
|
||||
globalScore: data.globalScore,
|
||||
binaryDecision: data.binaryDecision,
|
||||
feedbackText: data.feedbackText,
|
||||
})
|
||||
|
||||
setAutosaveStatus('saved')
|
||||
setLastSaved(new Date())
|
||||
|
||||
// Reset to idle after a few seconds
|
||||
setTimeout(() => setAutosaveStatus('idle'), 3000)
|
||||
} catch (error) {
|
||||
console.error('Autosave failed:', error)
|
||||
setAutosaveStatus('error')
|
||||
}
|
||||
},
|
||||
3000 // 3 second debounce
|
||||
)
|
||||
|
||||
// Watch form values and trigger autosave
|
||||
const watchedValues = watch()
|
||||
|
||||
useEffect(() => {
|
||||
if (isDirty && isVotingOpen) {
|
||||
debouncedAutosave(watchedValues)
|
||||
}
|
||||
}, [watchedValues, isDirty, isVotingOpen, debouncedAutosave])
|
||||
|
||||
// Submit handler
|
||||
const onSubmit = async (data: EvaluationFormData) => {
|
||||
if (!currentEvaluationId) return
|
||||
|
||||
try {
|
||||
await submit.mutateAsync({
|
||||
id: currentEvaluationId,
|
||||
criterionScoresJson: data.criterionScores,
|
||||
globalScore: data.globalScore,
|
||||
binaryDecision: data.binaryDecision,
|
||||
feedbackText: data.feedbackText,
|
||||
})
|
||||
|
||||
// Invalidate queries and redirect
|
||||
utils.assignment.myAssignments.invalidate()
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`/jury/projects/${assignmentId.split('-')[0]}/evaluation`)
|
||||
router.refresh()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Submit failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const isSubmitted = initialData?.status === 'SUBMITTED' || initialData?.status === 'LOCKED'
|
||||
const isReadOnly = isSubmitted || !isVotingOpen
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Status bar */}
|
||||
<div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
|
||||
{projectTitle}
|
||||
</h2>
|
||||
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!isValid || submit.isPending}
|
||||
>
|
||||
{submit.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Submit Evaluation
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Submit Evaluation?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Once submitted, you cannot edit your evaluation. Please review
|
||||
your scores and feedback before confirming.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={submit.isPending}
|
||||
>
|
||||
{submit.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Confirm Submit
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReadOnly && (
|
||||
<Badge variant="secondary">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{isSubmitted ? 'Submitted' : 'Read Only'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criteria scoring */}
|
||||
{criteria.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Evaluation Criteria</CardTitle>
|
||||
<CardDescription>
|
||||
Rate the project on each criterion below
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{criteria.map((criterion) => (
|
||||
<CriterionField
|
||||
key={criterion.id}
|
||||
criterion={criterion}
|
||||
control={control}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Global score */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Overall Score</CardTitle>
|
||||
<CardDescription>
|
||||
Rate the project overall on a scale of 1 to 10
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Controller
|
||||
name="globalScore"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Poor</span>
|
||||
<span className="text-4xl font-bold">{field.value}</span>
|
||||
<span className="text-sm text-muted-foreground">Excellent</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(v) => field.onChange(v[0])}
|
||||
disabled={isReadOnly}
|
||||
className="py-4"
|
||||
/>
|
||||
<div className="flex justify-between">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
onClick={() => !isReadOnly && field.onChange(num)}
|
||||
disabled={isReadOnly}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
||||
field.value === num
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80',
|
||||
isReadOnly && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Binary decision */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Recommendation</CardTitle>
|
||||
<CardDescription>
|
||||
Do you recommend this project to advance to the next round?
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Controller
|
||||
name="binaryDecision"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'flex-1 h-20',
|
||||
field.value && 'bg-green-600 hover:bg-green-700'
|
||||
)}
|
||||
onClick={() => !isReadOnly && field.onChange(true)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-6 w-6" />
|
||||
Yes, Recommend
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={!field.value ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'flex-1 h-20',
|
||||
!field.value && 'bg-red-600 hover:bg-red-700'
|
||||
)}
|
||||
onClick={() => !isReadOnly && field.onChange(false)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-6 w-6" />
|
||||
No, Do Not Recommend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feedback text */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Written Feedback</CardTitle>
|
||||
<CardDescription>
|
||||
Provide constructive feedback for this project (minimum 10 characters)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Controller
|
||||
name="feedbackText"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Share your thoughts on the project's strengths, weaknesses, and potential..."
|
||||
rows={6}
|
||||
maxLength={5000}
|
||||
disabled={isReadOnly}
|
||||
className={cn(
|
||||
errors.feedbackText && 'border-destructive'
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
{errors.feedbackText ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.feedbackText.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{field.value.length} characters
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error display */}
|
||||
{submit.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">
|
||||
{submit.error.message || 'Failed to submit evaluation. Please try again.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bottom submit button for mobile */}
|
||||
{!isReadOnly && (
|
||||
<div className="flex justify-end pb-safe">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
disabled={!isValid || submit.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{submit.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Submit Evaluation
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Submit Evaluation?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Once submitted, you cannot edit your evaluation. Please review
|
||||
your scores and feedback before confirming.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={submit.isPending}
|
||||
>
|
||||
{submit.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Confirm Submit
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// Criterion field component
|
||||
function CriterionField({
|
||||
criterion,
|
||||
control,
|
||||
disabled,
|
||||
}: {
|
||||
criterion: Criterion
|
||||
control: any
|
||||
disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
<Controller
|
||||
name={`criterionScores.${criterion.id}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-medium">{criterion.label}</Label>
|
||||
{criterion.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{criterion.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{field.value}/{criterion.scale}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-4">1</span>
|
||||
<Slider
|
||||
min={1}
|
||||
max={criterion.scale}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(v) => field.onChange(v[0])}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-4">{criterion.scale}</span>
|
||||
</div>
|
||||
|
||||
{/* Visual rating buttons */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map((num) => (
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
onClick={() => !disabled && field.onChange(num)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||
field.value === num
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: field.value > num
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted hover:bg-muted/80',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Autosave indicator component
|
||||
function AutosaveIndicator({
|
||||
status,
|
||||
lastSaved,
|
||||
}: {
|
||||
status: 'idle' | 'saving' | 'saved' | 'error'
|
||||
lastSaved: Date | null
|
||||
}) {
|
||||
if (status === 'idle' && lastSaved) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
Saved
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'saving') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="hidden sm:inline">Saving...</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'saved') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Saved</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Save failed</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
326
src/components/forms/form-wizard.tsx
Normal file
326
src/components/forms/form-wizard.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-react'
|
||||
|
||||
export interface WizardStep {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
isOptional?: boolean
|
||||
}
|
||||
|
||||
interface FormWizardContextValue {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
steps: WizardStep[]
|
||||
goToStep: (step: number) => void
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
canGoNext: boolean
|
||||
setCanGoNext: (can: boolean) => void
|
||||
}
|
||||
|
||||
const FormWizardContext = React.createContext<FormWizardContextValue | null>(null)
|
||||
|
||||
export function useFormWizard() {
|
||||
const context = React.useContext(FormWizardContext)
|
||||
if (!context) {
|
||||
throw new Error('useFormWizard must be used within a FormWizard')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface FormWizardProps {
|
||||
steps: WizardStep[]
|
||||
children: React.ReactNode
|
||||
onComplete?: () => void | Promise<void>
|
||||
isSubmitting?: boolean
|
||||
submitLabel?: string
|
||||
className?: string
|
||||
showStepIndicator?: boolean
|
||||
allowStepNavigation?: boolean
|
||||
}
|
||||
|
||||
export function FormWizard({
|
||||
steps,
|
||||
children,
|
||||
onComplete,
|
||||
isSubmitting = false,
|
||||
submitLabel = 'Submit',
|
||||
className,
|
||||
showStepIndicator = true,
|
||||
allowStepNavigation = false,
|
||||
}: FormWizardProps) {
|
||||
const [currentStep, setCurrentStep] = React.useState(0)
|
||||
const [canGoNext, setCanGoNext] = React.useState(true)
|
||||
const [direction, setDirection] = React.useState(0) // -1 for back, 1 for forward
|
||||
|
||||
const totalSteps = steps.length
|
||||
const isFirstStep = currentStep === 0
|
||||
const isLastStep = currentStep === totalSteps - 1
|
||||
|
||||
const goToStep = React.useCallback((step: number) => {
|
||||
if (step >= 0 && step < totalSteps) {
|
||||
setDirection(step > currentStep ? 1 : -1)
|
||||
setCurrentStep(step)
|
||||
}
|
||||
}, [currentStep, totalSteps])
|
||||
|
||||
const nextStep = React.useCallback(() => {
|
||||
if (currentStep < totalSteps - 1) {
|
||||
setDirection(1)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}, [currentStep, totalSteps])
|
||||
|
||||
const prevStep = React.useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
setDirection(-1)
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isLastStep && onComplete) {
|
||||
await onComplete()
|
||||
} else {
|
||||
nextStep()
|
||||
}
|
||||
}
|
||||
|
||||
const contextValue: FormWizardContextValue = {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
steps,
|
||||
goToStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canGoNext,
|
||||
setCanGoNext,
|
||||
}
|
||||
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
const currentChild = childrenArray[currentStep]
|
||||
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 100 : -100,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction < 0 ? 100 : -100,
|
||||
opacity: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
return (
|
||||
<FormWizardContext.Provider value={contextValue}>
|
||||
<div className={cn('flex min-h-[600px] flex-col', className)}>
|
||||
{showStepIndicator && (
|
||||
<StepIndicator
|
||||
steps={steps}
|
||||
currentStep={currentStep}
|
||||
allowNavigation={allowStepNavigation}
|
||||
onStepClick={allowStepNavigation ? goToStep : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<AnimatePresence initial={false} custom={direction} mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: 'spring', stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<div className="h-full px-1 py-6">
|
||||
{currentChild}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={prevStep}
|
||||
disabled={isFirstStep || isSubmitting}
|
||||
className={cn(isFirstStep && 'invisible')}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={!canGoNext || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : isLastStep ? (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{submitLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormWizardContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepIndicatorProps {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
allowNavigation?: boolean
|
||||
onStepClick?: (step: number) => void
|
||||
}
|
||||
|
||||
export function StepIndicator({
|
||||
steps,
|
||||
currentStep,
|
||||
allowNavigation = false,
|
||||
onStepClick,
|
||||
}: StepIndicatorProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Progress bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>Step {currentStep + 1} of {steps.length}</span>
|
||||
<span>{Math.round(((currentStep + 1) / steps.length) * 100)}% complete</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-primary to-primary/80"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((currentStep + 1) / steps.length) * 100}%` }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep
|
||||
const isCurrent = index === currentStep
|
||||
const isClickable = allowNavigation && (isCompleted || isCurrent)
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isClickable && onStepClick?.(index)}
|
||||
disabled={!isClickable}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2',
|
||||
isClickable && 'cursor-pointer',
|
||||
!isClickable && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors',
|
||||
isCompleted && 'border-primary bg-primary text-primary-foreground',
|
||||
isCurrent && 'border-primary text-primary',
|
||||
!isCompleted && !isCurrent && 'border-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="h-5 w-5" />
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'hidden text-xs font-medium md:block',
|
||||
isCurrent && 'text-primary',
|
||||
!isCurrent && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 flex-1 mx-2',
|
||||
index < currentStep ? 'bg-primary' : 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface WizardStepContentProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function WizardStepContent({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: WizardStepContentProps) {
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', className)}>
|
||||
{(title || description) && (
|
||||
<div className="mb-8 text-center">
|
||||
{title && (
|
||||
<h2 className="text-2xl font-semibold tracking-tight md:text-3xl">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
489
src/components/forms/notion-import-form.tsx
Normal file
489
src/components/forms/notion-import-form.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Database,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NotionImportFormProps {
|
||||
roundId: string
|
||||
roundName: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
|
||||
|
||||
export function NotionImportForm({
|
||||
roundId,
|
||||
roundName,
|
||||
onSuccess,
|
||||
}: NotionImportFormProps) {
|
||||
const [step, setStep] = useState<Step>('connect')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [databaseId, setDatabaseId] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null)
|
||||
|
||||
// Mapping state
|
||||
const [mappings, setMappings] = useState({
|
||||
title: '',
|
||||
teamName: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
})
|
||||
const [includeUnmapped, setIncludeUnmapped] = useState(true)
|
||||
|
||||
// Results
|
||||
const [importResults, setImportResults] = useState<{
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: Array<{ recordId: string; error: string }>
|
||||
} | null>(null)
|
||||
|
||||
const testConnection = trpc.notionImport.testConnection.useMutation()
|
||||
const { data: schema, refetch: refetchSchema } =
|
||||
trpc.notionImport.getDatabaseSchema.useQuery(
|
||||
{ apiKey, databaseId },
|
||||
{ enabled: false }
|
||||
)
|
||||
const { data: preview, refetch: refetchPreview } =
|
||||
trpc.notionImport.previewData.useQuery(
|
||||
{ apiKey, databaseId, limit: 5 },
|
||||
{ enabled: false }
|
||||
)
|
||||
const importMutation = trpc.notionImport.importProjects.useMutation()
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!apiKey || !databaseId) {
|
||||
toast.error('Please enter both API key and database ID')
|
||||
return
|
||||
}
|
||||
|
||||
setIsConnecting(true)
|
||||
setConnectionError(null)
|
||||
|
||||
try {
|
||||
const result = await testConnection.mutateAsync({ apiKey })
|
||||
if (!result.success) {
|
||||
setConnectionError(result.error || 'Connection failed')
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch schema
|
||||
await refetchSchema()
|
||||
setStep('map')
|
||||
} catch (error) {
|
||||
setConnectionError(
|
||||
error instanceof Error ? error.message : 'Connection failed'
|
||||
)
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!mappings.title) {
|
||||
toast.error('Please map the Title field')
|
||||
return
|
||||
}
|
||||
|
||||
await refetchPreview()
|
||||
setStep('preview')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
setStep('import')
|
||||
|
||||
try {
|
||||
const result = await importMutation.mutateAsync({
|
||||
apiKey,
|
||||
databaseId,
|
||||
roundId,
|
||||
mappings: {
|
||||
title: mappings.title,
|
||||
teamName: mappings.teamName || undefined,
|
||||
description: mappings.description || undefined,
|
||||
tags: mappings.tags || undefined,
|
||||
},
|
||||
includeUnmappedInMetadata: includeUnmapped,
|
||||
})
|
||||
|
||||
setImportResults(result)
|
||||
setStep('complete')
|
||||
|
||||
if (result.imported > 0) {
|
||||
toast.success(`Imported ${result.imported} projects`)
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Import failed'
|
||||
)
|
||||
setStep('preview')
|
||||
}
|
||||
}
|
||||
|
||||
const properties = schema?.properties || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
|
||||
<div key={s} className="flex items-center">
|
||||
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
step === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Connect */}
|
||||
{step === 'connect' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Connect to Notion
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Notion API key and database ID to connect
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">Notion API Key</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
placeholder="secret_..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an integration at{' '}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
notion.so/my-integrations
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="databaseId">Database ID</Label>
|
||||
<Input
|
||||
id="databaseId"
|
||||
placeholder="abc123..."
|
||||
value={databaseId}
|
||||
onChange={(e) => setDatabaseId(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The ID from your Notion database URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{connectionError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Connection Failed</AlertTitle>
|
||||
<AlertDescription>{connectionError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !apiKey || !databaseId}
|
||||
>
|
||||
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Connect
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Map columns */}
|
||||
{step === 'map' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Map Columns</CardTitle>
|
||||
<CardDescription>
|
||||
Map Notion properties to project fields. Database: {schema?.title}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings.title}
|
||||
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{properties.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}{' '}
|
||||
<span className="text-muted-foreground">({p.type})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Team Name</Label>
|
||||
<Select
|
||||
value={mappings.teamName}
|
||||
onValueChange={(v) =>
|
||||
setMappings((m) => ({ ...m, teamName: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{properties.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Select
|
||||
value={mappings.description}
|
||||
onValueChange={(v) =>
|
||||
setMappings((m) => ({ ...m, description: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{properties.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<Select
|
||||
value={mappings.tags}
|
||||
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select property (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{properties
|
||||
.filter((p) => p.type === 'multi_select' || p.type === 'select')
|
||||
.map((p) => (
|
||||
<SelectItem key={p.id} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeUnmapped"
|
||||
checked={includeUnmapped}
|
||||
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
|
||||
/>
|
||||
<Label htmlFor="includeUnmapped" className="font-normal">
|
||||
Store unmapped columns in metadata
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('connect')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handlePreview} disabled={!mappings.title}>
|
||||
Preview
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Preview */}
|
||||
{step === 'preview' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview Import</CardTitle>
|
||||
<CardDescription>
|
||||
Review the first {preview?.count || 0} records before importing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Title</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Team</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview?.records.map((record, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="px-4 py-2">
|
||||
{String(record.properties[mappings.title] || '-')}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{mappings.teamName
|
||||
? String(record.properties[mappings.teamName] || '-')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{mappings.tags && record.properties[mappings.tags]
|
||||
? (
|
||||
record.properties[mappings.tags] as string[]
|
||||
).map((tag, j) => (
|
||||
<Badge key={j} variant="secondary" className="mr-1">
|
||||
{tag}
|
||||
</Badge>
|
||||
))
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Ready to import</AlertTitle>
|
||||
<AlertDescription>
|
||||
This will import all records from the Notion database into{' '}
|
||||
<strong>{roundName}</strong>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('map')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleImport}>
|
||||
Import All Records
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 4: Importing */}
|
||||
{step === 'import' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
|
||||
<p className="text-lg font-medium">Importing projects...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please wait while we import your data from Notion
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 5: Complete */}
|
||||
{step === 'complete' && importResults && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||
<p className="text-lg font-medium">Import Complete</p>
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{importResults.imported}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">projects imported</p>
|
||||
</div>
|
||||
{importResults.skipped > 0 && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{importResults.skipped} records skipped
|
||||
</p>
|
||||
)}
|
||||
{importResults.errors.length > 0 && (
|
||||
<div className="mt-4 w-full max-w-md">
|
||||
<p className="text-sm font-medium text-destructive mb-2">
|
||||
Errors ({importResults.errors.length}):
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
|
||||
{importResults.errors.slice(0, 5).map((e, i) => (
|
||||
<p key={i}>
|
||||
{e.recordId}: {e.error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
513
src/components/forms/round-type-settings.tsx
Normal file
513
src/components/forms/round-type-settings.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Filter, ClipboardCheck, Zap, Info } from 'lucide-react'
|
||||
import {
|
||||
type FilteringRoundSettings,
|
||||
type EvaluationRoundSettings,
|
||||
type LiveEventRoundSettings,
|
||||
defaultFilteringSettings,
|
||||
defaultEvaluationSettings,
|
||||
defaultLiveEventSettings,
|
||||
roundTypeLabels,
|
||||
roundTypeDescriptions,
|
||||
} from '@/types/round-settings'
|
||||
|
||||
interface RoundTypeSettingsProps {
|
||||
roundType: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'
|
||||
onRoundTypeChange: (type: 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT') => void
|
||||
settings: Record<string, unknown>
|
||||
onSettingsChange: (settings: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
const roundTypeIcons = {
|
||||
FILTERING: Filter,
|
||||
EVALUATION: ClipboardCheck,
|
||||
LIVE_EVENT: Zap,
|
||||
}
|
||||
|
||||
export function RoundTypeSettings({
|
||||
roundType,
|
||||
onRoundTypeChange,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
}: RoundTypeSettingsProps) {
|
||||
const Icon = roundTypeIcons[roundType]
|
||||
|
||||
// Get typed settings with defaults
|
||||
const getFilteringSettings = (): FilteringRoundSettings => ({
|
||||
...defaultFilteringSettings,
|
||||
...(settings as Partial<FilteringRoundSettings>),
|
||||
})
|
||||
|
||||
const getEvaluationSettings = (): EvaluationRoundSettings => ({
|
||||
...defaultEvaluationSettings,
|
||||
...(settings as Partial<EvaluationRoundSettings>),
|
||||
})
|
||||
|
||||
const getLiveEventSettings = (): LiveEventRoundSettings => ({
|
||||
...defaultLiveEventSettings,
|
||||
...(settings as Partial<LiveEventRoundSettings>),
|
||||
})
|
||||
|
||||
const updateSetting = <T extends Record<string, unknown>>(
|
||||
key: keyof T,
|
||||
value: T[keyof T]
|
||||
) => {
|
||||
onSettingsChange({ ...settings, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5" />
|
||||
Round Type & Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the type and behavior for this round
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Round Type Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Round Type</Label>
|
||||
<Select value={roundType} onValueChange={(v) => onRoundTypeChange(v as typeof roundType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
|
||||
const TypeIcon = roundTypeIcons[type]
|
||||
return (
|
||||
<SelectItem key={type} value={type}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-4 w-4" />
|
||||
{roundTypeLabels[type]}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{roundTypeDescriptions[roundType]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Type-specific settings */}
|
||||
{roundType === 'FILTERING' && (
|
||||
<FilteringSettings
|
||||
settings={getFilteringSettings()}
|
||||
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{roundType === 'EVALUATION' && (
|
||||
<EvaluationSettings
|
||||
settings={getEvaluationSettings()}
|
||||
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{roundType === 'LIVE_EVENT' && (
|
||||
<LiveEventSettings
|
||||
settings={getLiveEventSettings()}
|
||||
onChange={(s) => onSettingsChange(s as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Filtering Round Settings
|
||||
function FilteringSettings({
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
settings: FilteringRoundSettings
|
||||
onChange: (settings: FilteringRoundSettings) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6 border-t pt-4">
|
||||
<h4 className="font-medium">Filtering Settings</h4>
|
||||
|
||||
{/* Target Advancing */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetAdvancing">Target Projects to Advance</Label>
|
||||
<Input
|
||||
id="targetAdvancing"
|
||||
type="number"
|
||||
min="1"
|
||||
value={settings.targetAdvancing}
|
||||
onChange={(e) =>
|
||||
onChange({ ...settings, targetAdvancing: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The target number of projects to advance to the next round
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-elimination */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Auto-Elimination</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically flag projects below threshold
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.autoEliminationEnabled}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, autoEliminationEnabled: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.autoEliminationEnabled && (
|
||||
<div className="ml-6 space-y-4 border-l-2 pl-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="threshold">Score Threshold</Label>
|
||||
<Input
|
||||
id="threshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={settings.autoEliminationThreshold}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
autoEliminationThreshold: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects averaging below this score will be flagged
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minReviews">Minimum Reviews</Label>
|
||||
<Input
|
||||
id="minReviews"
|
||||
type="number"
|
||||
min="1"
|
||||
value={settings.autoEliminationMinReviews}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
autoEliminationMinReviews: parseInt(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Min reviews before auto-elimination applies
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Auto-elimination only flags projects for review. Final decisions require
|
||||
admin approval.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display Options */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Display Options</h5>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showAverage">Show Average Score</Label>
|
||||
<Switch
|
||||
id="showAverage"
|
||||
checked={settings.showAverageScore}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showAverageScore: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showRanking">Show Ranking</Label>
|
||||
<Switch
|
||||
id="showRanking"
|
||||
checked={settings.showRanking}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showRanking: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Evaluation Round Settings
|
||||
function EvaluationSettings({
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
settings: EvaluationRoundSettings
|
||||
onChange: (settings: EvaluationRoundSettings) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6 border-t pt-4">
|
||||
<h4 className="font-medium">Evaluation Settings</h4>
|
||||
|
||||
{/* Target Finalists */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetFinalists">Target Finalists</Label>
|
||||
<Input
|
||||
id="targetFinalists"
|
||||
type="number"
|
||||
min="1"
|
||||
value={settings.targetFinalists}
|
||||
onChange={(e) =>
|
||||
onChange({ ...settings, targetFinalists: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The target number of finalists to select
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Requirements</h5>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Require All Criteria</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Jury must score all criteria before submission
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.requireAllCriteria}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, requireAllCriteria: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Detailed Criteria Required</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use detailed evaluation criteria
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.detailedCriteriaRequired}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, detailedCriteriaRequired: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minFeedback">Minimum Feedback Length</Label>
|
||||
<Input
|
||||
id="minFeedback"
|
||||
type="number"
|
||||
min="0"
|
||||
value={settings.minimumFeedbackLength}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
minimumFeedbackLength: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Minimum characters for feedback comments (0 = optional)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Options */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Display Options</h5>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showAverage">Show Average Score</Label>
|
||||
<Switch
|
||||
id="showAverage"
|
||||
checked={settings.showAverageScore}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showAverageScore: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showRanking">Show Ranking</Label>
|
||||
<Switch
|
||||
id="showRanking"
|
||||
checked={settings.showRanking}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showRanking: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Live Event Round Settings
|
||||
function LiveEventSettings({
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
settings: LiveEventRoundSettings
|
||||
onChange: (settings: LiveEventRoundSettings) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6 border-t pt-4">
|
||||
<h4 className="font-medium">Live Event Settings</h4>
|
||||
|
||||
{/* Presentation */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Presentation</h5>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration">Presentation Duration (minutes)</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={settings.presentationDurationMinutes}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
presentationDurationMinutes: parseInt(e.target.value) || 5,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voting */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Voting</h5>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="votingWindow">Voting Window (seconds)</Label>
|
||||
<Input
|
||||
id="votingWindow"
|
||||
type="number"
|
||||
min="10"
|
||||
max="300"
|
||||
value={settings.votingWindowSeconds}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
votingWindowSeconds: parseInt(e.target.value) || 30,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Duration of the voting window after each presentation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Allow Vote Change</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow jury to change their vote during the window
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.allowVoteChange}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, allowVoteChange: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Display</h5>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Show Live Scores</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display scores in real-time during the event
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.showLiveScores}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, showLiveScores: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Display Mode</Label>
|
||||
<Select
|
||||
value={settings.displayMode}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
...settings,
|
||||
displayMode: v as 'SCORES' | 'RANKING' | 'NONE',
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SCORES">Show Scores</SelectItem>
|
||||
<SelectItem value="RANKING">Show Ranking</SelectItem>
|
||||
<SelectItem value="NONE">Hide Until End</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How results are displayed on the public screen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Presentation order can be configured in the Live Voting section once the round
|
||||
is activated.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
516
src/components/forms/typeform-import-form.tsx
Normal file
516
src/components/forms/typeform-import-form.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface TypeformImportFormProps {
|
||||
roundId: string
|
||||
roundName: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
type Step = 'connect' | 'map' | 'preview' | 'import' | 'complete'
|
||||
|
||||
export function TypeformImportForm({
|
||||
roundId,
|
||||
roundName,
|
||||
onSuccess,
|
||||
}: TypeformImportFormProps) {
|
||||
const [step, setStep] = useState<Step>('connect')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [formId, setFormId] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null)
|
||||
|
||||
// Mapping state
|
||||
const [mappings, setMappings] = useState({
|
||||
title: '',
|
||||
teamName: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
email: '',
|
||||
})
|
||||
const [includeUnmapped, setIncludeUnmapped] = useState(true)
|
||||
|
||||
// Results
|
||||
const [importResults, setImportResults] = useState<{
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: Array<{ responseId: string; error: string }>
|
||||
} | null>(null)
|
||||
|
||||
const testConnection = trpc.typeformImport.testConnection.useMutation()
|
||||
const { data: schema, refetch: refetchSchema } =
|
||||
trpc.typeformImport.getFormSchema.useQuery(
|
||||
{ apiKey, formId },
|
||||
{ enabled: false }
|
||||
)
|
||||
const { data: preview, refetch: refetchPreview } =
|
||||
trpc.typeformImport.previewResponses.useQuery(
|
||||
{ apiKey, formId, limit: 5 },
|
||||
{ enabled: false }
|
||||
)
|
||||
const importMutation = trpc.typeformImport.importProjects.useMutation()
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!apiKey || !formId) {
|
||||
toast.error('Please enter both API key and form ID')
|
||||
return
|
||||
}
|
||||
|
||||
setIsConnecting(true)
|
||||
setConnectionError(null)
|
||||
|
||||
try {
|
||||
const result = await testConnection.mutateAsync({ apiKey })
|
||||
if (!result.success) {
|
||||
setConnectionError(result.error || 'Connection failed')
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch schema
|
||||
await refetchSchema()
|
||||
setStep('map')
|
||||
} catch (error) {
|
||||
setConnectionError(
|
||||
error instanceof Error ? error.message : 'Connection failed'
|
||||
)
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!mappings.title) {
|
||||
toast.error('Please map the Title field')
|
||||
return
|
||||
}
|
||||
|
||||
await refetchPreview()
|
||||
setStep('preview')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
setStep('import')
|
||||
|
||||
try {
|
||||
const result = await importMutation.mutateAsync({
|
||||
apiKey,
|
||||
formId,
|
||||
roundId,
|
||||
mappings: {
|
||||
title: mappings.title,
|
||||
teamName: mappings.teamName || undefined,
|
||||
description: mappings.description || undefined,
|
||||
tags: mappings.tags || undefined,
|
||||
email: mappings.email || undefined,
|
||||
},
|
||||
includeUnmappedInMetadata: includeUnmapped,
|
||||
})
|
||||
|
||||
setImportResults(result)
|
||||
setStep('complete')
|
||||
|
||||
if (result.imported > 0) {
|
||||
toast.success(`Imported ${result.imported} projects`)
|
||||
onSuccess?.()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Import failed'
|
||||
)
|
||||
setStep('preview')
|
||||
}
|
||||
}
|
||||
|
||||
const fields = schema?.fields || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{['connect', 'map', 'preview', 'import', 'complete'].map((s, i) => (
|
||||
<div key={s} className="flex items-center">
|
||||
{i > 0 && <div className="w-8 h-0.5 bg-muted mx-1" />}
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
step === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: ['connect', 'map', 'preview', 'import', 'complete'].indexOf(step) > i
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Connect */}
|
||||
{step === 'connect' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Connect to Typeform
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Typeform API key and form ID to connect
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">Typeform API Key</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
placeholder="tfp_..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your API key from{' '}
|
||||
<a
|
||||
href="https://admin.typeform.com/user/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Typeform Admin
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="formId">Form ID</Label>
|
||||
<Input
|
||||
id="formId"
|
||||
placeholder="abc123..."
|
||||
value={formId}
|
||||
onChange={(e) => setFormId(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The ID from your Typeform URL (e.g., typeform.com/to/ABC123)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{connectionError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Connection Failed</AlertTitle>
|
||||
<AlertDescription>{connectionError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || !apiKey || !formId}
|
||||
>
|
||||
{isConnecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Connect
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Map columns */}
|
||||
{step === 'map' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Map Questions</CardTitle>
|
||||
<CardDescription>
|
||||
Map Typeform questions to project fields. Form: {schema?.title}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings.title}
|
||||
onValueChange={(v) => setMappings((m) => ({ ...m, title: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select question" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fields.map((f) => (
|
||||
<SelectItem key={f.id} value={f.title}>
|
||||
<div className="flex flex-col">
|
||||
<span className="truncate max-w-[200px]">{f.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{f.type}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Team Name</Label>
|
||||
<Select
|
||||
value={mappings.teamName}
|
||||
onValueChange={(v) =>
|
||||
setMappings((m) => ({ ...m, teamName: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select question (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{fields.map((f) => (
|
||||
<SelectItem key={f.id} value={f.title}>
|
||||
{f.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Select
|
||||
value={mappings.description}
|
||||
onValueChange={(v) =>
|
||||
setMappings((m) => ({ ...m, description: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select question (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{fields
|
||||
.filter((f) => f.type === 'long_text' || f.type === 'short_text')
|
||||
.map((f) => (
|
||||
<SelectItem key={f.id} value={f.title}>
|
||||
{f.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<Select
|
||||
value={mappings.tags}
|
||||
onValueChange={(v) => setMappings((m) => ({ ...m, tags: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select question (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{fields
|
||||
.filter(
|
||||
(f) =>
|
||||
f.type === 'multiple_choice' || f.type === 'dropdown'
|
||||
)
|
||||
.map((f) => (
|
||||
<SelectItem key={f.id} value={f.title}>
|
||||
{f.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Select
|
||||
value={mappings.email}
|
||||
onValueChange={(v) => setMappings((m) => ({ ...m, email: v }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select question (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{fields
|
||||
.filter((f) => f.type === 'email')
|
||||
.map((f) => (
|
||||
<SelectItem key={f.id} value={f.title}>
|
||||
{f.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="includeUnmapped"
|
||||
checked={includeUnmapped}
|
||||
onCheckedChange={(c) => setIncludeUnmapped(!!c)}
|
||||
/>
|
||||
<Label htmlFor="includeUnmapped" className="font-normal">
|
||||
Store unmapped answers in metadata
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('connect')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handlePreview} disabled={!mappings.title}>
|
||||
Preview
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Preview */}
|
||||
{step === 'preview' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview Import</CardTitle>
|
||||
<CardDescription>
|
||||
Review the first {preview?.count || 0} responses before importing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Title</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Team</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview?.records.map((record, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="px-4 py-2 truncate max-w-[200px]">
|
||||
{String(record[mappings.title] || '-')}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{mappings.teamName
|
||||
? String(record[mappings.teamName] || '-')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{mappings.email
|
||||
? String(record[mappings.email] || '-')
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Ready to import</AlertTitle>
|
||||
<AlertDescription>
|
||||
This will import all responses from the Typeform into{' '}
|
||||
<strong>{roundName}</strong>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('map')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleImport}>
|
||||
Import All Responses
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 4: Importing */}
|
||||
{step === 'import' && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
|
||||
<p className="text-lg font-medium">Importing responses...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please wait while we import your data from Typeform
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 5: Complete */}
|
||||
{step === 'complete' && importResults && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||
<p className="text-lg font-medium">Import Complete</p>
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{importResults.imported}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">projects imported</p>
|
||||
</div>
|
||||
{importResults.skipped > 0 && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{importResults.skipped} responses skipped
|
||||
</p>
|
||||
)}
|
||||
{importResults.errors.length > 0 && (
|
||||
<div className="mt-4 w-full max-w-md">
|
||||
<p className="text-sm font-medium text-destructive mb-2">
|
||||
Errors ({importResults.errors.length}):
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto text-xs text-muted-foreground">
|
||||
{importResults.errors.slice(0, 5).map((e, i) => (
|
||||
<p key={i}>
|
||||
{e.responseId}: {e.error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user