Fix multiple UI/UX issues and invite token migration
Build and Push Docker Image / build (push) Successful in 8m39s
Details
Build and Push Docker Image / build (push) Successful in 8m39s
Details
Fixes: - Round edit: Add cache invalidation for voting dates - Criteria weights: Replace number input with visual slider - Member invite: Per-member expertise tags with suggestions - Tags now added per member, not globally - Comma key support for quick tag entry - Suggested tags based on ocean/business expertise - Accept-invite: Add Suspense boundary for useSearchParams - Add missing inviteToken columns migration The invite token columns were accidentally skipped in prototype1 migration. This adds them with IF NOT EXISTS checks. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3986da172f
commit
8be740a4fb
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- Add invite token columns to User table if they don't exist
|
||||||
|
-- These were accidentally skipped in the prototype1_improvements migration
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Add inviteToken column if it doesn't exist
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'User' AND column_name = 'inviteToken'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "User" ADD COLUMN "inviteToken" TEXT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Add inviteTokenExpiresAt column if it doesn't exist
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'User' AND column_name = 'inviteTokenExpiresAt'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "User" ADD COLUMN "inviteTokenExpiresAt" TIMESTAMP(3);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Create unique index on inviteToken if it doesn't exist
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "User_inviteToken_key" ON "User"("inviteToken");
|
||||||
|
|
@ -53,12 +53,15 @@ interface MemberRow {
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
role: Role
|
role: Role
|
||||||
|
expertiseTags: string[]
|
||||||
|
tagInput: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedUser {
|
interface ParsedUser {
|
||||||
email: string
|
email: string
|
||||||
name?: string
|
name?: string
|
||||||
role: Role
|
role: Role
|
||||||
|
expertiseTags?: string[]
|
||||||
isValid: boolean
|
isValid: boolean
|
||||||
error?: string
|
error?: string
|
||||||
isDuplicate?: boolean
|
isDuplicate?: boolean
|
||||||
|
|
@ -78,15 +81,35 @@ function nextRowId(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow {
|
function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow {
|
||||||
return { id: nextRowId(), name: '', email: '', role }
|
return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Common expertise tags for suggestions
|
||||||
|
const SUGGESTED_TAGS = [
|
||||||
|
'Marine Biology',
|
||||||
|
'Ocean Conservation',
|
||||||
|
'Coral Reef Restoration',
|
||||||
|
'Sustainable Fisheries',
|
||||||
|
'Marine Policy',
|
||||||
|
'Ocean Technology',
|
||||||
|
'Climate Science',
|
||||||
|
'Biodiversity',
|
||||||
|
'Blue Economy',
|
||||||
|
'Coastal Management',
|
||||||
|
'Oceanography',
|
||||||
|
'Marine Pollution',
|
||||||
|
'Plastic Reduction',
|
||||||
|
'Renewable Energy',
|
||||||
|
'Business Development',
|
||||||
|
'Impact Investment',
|
||||||
|
'Social Entrepreneurship',
|
||||||
|
'Startup Mentoring',
|
||||||
|
]
|
||||||
|
|
||||||
export default function MemberInvitePage() {
|
export default function MemberInvitePage() {
|
||||||
const [step, setStep] = useState<Step>('input')
|
const [step, setStep] = useState<Step>('input')
|
||||||
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
||||||
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
||||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
|
||||||
const [tagInput, setTagInput] = useState('')
|
|
||||||
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
|
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
|
||||||
const [sendProgress, setSendProgress] = useState(0)
|
const [sendProgress, setSendProgress] = useState(0)
|
||||||
const [result, setResult] = useState<{
|
const [result, setResult] = useState<{
|
||||||
|
|
@ -97,7 +120,7 @@ export default function MemberInvitePage() {
|
||||||
const bulkCreate = trpc.user.bulkCreate.useMutation()
|
const bulkCreate = trpc.user.bulkCreate.useMutation()
|
||||||
|
|
||||||
// --- Manual entry helpers ---
|
// --- Manual entry helpers ---
|
||||||
const updateRow = (id: string, field: keyof MemberRow, value: string) => {
|
const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => {
|
||||||
setRows((prev) =>
|
setRows((prev) =>
|
||||||
prev.map((r) => (r.id === id ? { ...r, [field]: value } : r))
|
prev.map((r) => (r.id === id ? { ...r, [field]: value } : r))
|
||||||
)
|
)
|
||||||
|
|
@ -115,6 +138,38 @@ export default function MemberInvitePage() {
|
||||||
setRows((prev) => [...prev, createEmptyRow(lastRole)])
|
setRows((prev) => [...prev, createEmptyRow(lastRole)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-row tag management
|
||||||
|
const addTagToRow = (id: string, tag: string) => {
|
||||||
|
const trimmed = tag.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => {
|
||||||
|
if (r.id !== id) return r
|
||||||
|
if (r.expertiseTags.includes(trimmed)) return r
|
||||||
|
return { ...r, expertiseTags: [...r.expertiseTags, trimmed], tagInput: '' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTagFromRow = (id: string, tag: string) => {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.id === id
|
||||||
|
? { ...r, expertiseTags: r.expertiseTags.filter((t) => t !== tag) }
|
||||||
|
: r
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get suggestions that haven't been added yet for a specific row
|
||||||
|
const getSuggestionsForRow = (row: MemberRow) => {
|
||||||
|
return SUGGESTED_TAGS.filter(
|
||||||
|
(tag) =>
|
||||||
|
!row.expertiseTags.includes(tag) &&
|
||||||
|
tag.toLowerCase().includes(row.tagInput.toLowerCase())
|
||||||
|
).slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
// --- CSV helpers ---
|
// --- CSV helpers ---
|
||||||
const handleCSVUpload = useCallback(
|
const handleCSVUpload = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -190,6 +245,7 @@ export default function MemberInvitePage() {
|
||||||
email,
|
email,
|
||||||
name: r.name.trim() || undefined,
|
name: r.name.trim() || undefined,
|
||||||
role: r.role,
|
role: r.role,
|
||||||
|
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
|
||||||
isValid: isValidFormat && !isDuplicate,
|
isValid: isValidFormat && !isDuplicate,
|
||||||
isDuplicate,
|
isDuplicate,
|
||||||
error: !isValidFormat
|
error: !isValidFormat
|
||||||
|
|
@ -208,17 +264,6 @@ export default function MemberInvitePage() {
|
||||||
setStep('preview')
|
setStep('preview')
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tags ---
|
|
||||||
const addTag = () => {
|
|
||||||
const tag = tagInput.trim()
|
|
||||||
if (tag && !expertiseTags.includes(tag)) {
|
|
||||||
setExpertiseTags([...expertiseTags, tag])
|
|
||||||
setTagInput('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const removeTag = (tag: string) =>
|
|
||||||
setExpertiseTags(expertiseTags.filter((t) => t !== tag))
|
|
||||||
|
|
||||||
// --- Summary ---
|
// --- Summary ---
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
const validUsers = parsedUsers.filter((u) => u.isValid)
|
const validUsers = parsedUsers.filter((u) => u.isValid)
|
||||||
|
|
@ -246,8 +291,7 @@ export default function MemberInvitePage() {
|
||||||
email: u.email,
|
email: u.email,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
role: u.role,
|
role: u.role,
|
||||||
expertiseTags:
|
expertiseTags: u.expertiseTags,
|
||||||
expertiseTags.length > 0 ? expertiseTags : undefined,
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
setSendProgress(100)
|
setSendProgress(100)
|
||||||
|
|
@ -311,68 +355,145 @@ export default function MemberInvitePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inputMethod === 'manual' ? (
|
{inputMethod === 'manual' ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{/* Column headers */}
|
{/* Member cards */}
|
||||||
<div className="hidden sm:grid sm:grid-cols-[1fr_1fr_140px_36px] gap-2 px-1">
|
{rows.map((row, index) => (
|
||||||
<Label className="text-xs text-muted-foreground">
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Label className="text-xs text-muted-foreground">
|
|
||||||
Email
|
|
||||||
</Label>
|
|
||||||
<Label className="text-xs text-muted-foreground">
|
|
||||||
Role
|
|
||||||
</Label>
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rows */}
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div
|
<div
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className="grid gap-2 sm:grid-cols-[1fr_1fr_140px_36px]"
|
className="rounded-lg border p-4 space-y-3"
|
||||||
>
|
>
|
||||||
<Input
|
<div className="flex items-center justify-between">
|
||||||
placeholder="Full name"
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
value={row.name}
|
Member {index + 1}
|
||||||
onChange={(e) =>
|
</span>
|
||||||
updateRow(row.id, 'name', e.target.value)
|
<Button
|
||||||
}
|
type="button"
|
||||||
/>
|
variant="ghost"
|
||||||
<Input
|
size="sm"
|
||||||
type="email"
|
onClick={() => removeRow(row.id)}
|
||||||
placeholder="email@example.com"
|
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||||
value={row.email}
|
>
|
||||||
onChange={(e) =>
|
<X className="h-4 w-4" />
|
||||||
updateRow(row.id, 'email', e.target.value)
|
</Button>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
<Select
|
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_140px]">
|
||||||
value={row.role}
|
<Input
|
||||||
onValueChange={(v) =>
|
placeholder="Full name"
|
||||||
updateRow(row.id, 'role', v)
|
value={row.name}
|
||||||
}
|
onChange={(e) =>
|
||||||
>
|
updateRow(row.id, 'name', e.target.value)
|
||||||
<SelectTrigger>
|
}
|
||||||
<SelectValue />
|
/>
|
||||||
</SelectTrigger>
|
<Input
|
||||||
<SelectContent>
|
type="email"
|
||||||
<SelectItem value="JURY_MEMBER">
|
placeholder="email@example.com"
|
||||||
Jury Member
|
value={row.email}
|
||||||
</SelectItem>
|
onChange={(e) =>
|
||||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
updateRow(row.id, 'email', e.target.value)
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
<Select
|
||||||
<Button
|
value={row.role}
|
||||||
type="button"
|
onValueChange={(v) =>
|
||||||
variant="ghost"
|
updateRow(row.id, 'role', v)
|
||||||
size="icon"
|
}
|
||||||
onClick={() => removeRow(row.id)}
|
>
|
||||||
className="text-muted-foreground hover:text-destructive"
|
<SelectTrigger>
|
||||||
>
|
<SelectValue />
|
||||||
<X className="h-4 w-4" />
|
</SelectTrigger>
|
||||||
</Button>
|
<SelectContent>
|
||||||
|
<SelectItem value="JURY_MEMBER">
|
||||||
|
Jury Member
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||||
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-member expertise tags */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Expertise Tags (optional)
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
placeholder="Type tag and press Enter or comma..."
|
||||||
|
value={row.tagInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(row.id, 'tagInput', e.target.value)
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault()
|
||||||
|
addTagToRow(row.id, row.tagInput)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag suggestions */}
|
||||||
|
{row.tagInput && getSuggestionsForRow(row).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{getSuggestionsForRow(row).map((suggestion) => (
|
||||||
|
<Button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={() => addTagToRow(row.id, suggestion)}
|
||||||
|
>
|
||||||
|
+ {suggestion}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick suggestions when empty */}
|
||||||
|
{!row.tagInput && row.expertiseTags.length === 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground mr-1">
|
||||||
|
Suggestions:
|
||||||
|
</span>
|
||||||
|
{SUGGESTED_TAGS.slice(0, 5).map((suggestion) => (
|
||||||
|
<Button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs px-2"
|
||||||
|
onClick={() => addTagToRow(row.id, suggestion)}
|
||||||
|
>
|
||||||
|
+ {suggestion}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Added tags */}
|
||||||
|
{row.expertiseTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{row.expertiseTags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-1 pr-1"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTagFromRow(row.id, tag)}
|
||||||
|
className="ml-1 hover:text-destructive rounded-full"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -417,44 +538,6 @@ export default function MemberInvitePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expertise tags */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="expertise">Expertise Tags (Optional)</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="expertise"
|
|
||||||
value={tagInput}
|
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
|
||||||
placeholder="e.g., Marine Biology"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
addTag()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button type="button" variant="outline" onClick={addTag}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{expertiseTags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{expertiseTags.map((tag) => (
|
|
||||||
<Badge key={tag} variant="secondary" className="gap-1">
|
|
||||||
{tag}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeTag(tag)}
|
|
||||||
className="ml-1 hover:text-destructive"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-between pt-4">
|
<div className="flex justify-between pt-4">
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,14 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
roundId,
|
roundId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateRound = trpc.round.update.useMutation({
|
const updateRound = trpc.round.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
// Invalidate cache to ensure fresh data
|
||||||
|
utils.round.get.invalidate({ id: roundId })
|
||||||
|
utils.round.list.invalidate()
|
||||||
router.push(`/admin/rounds/${roundId}`)
|
router.push(`/admin/rounds/${roundId}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, Suspense } from 'react'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { signIn } from 'next-auth/react'
|
import { signIn } from 'next-auth/react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -16,7 +16,7 @@ import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
||||||
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
|
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
|
||||||
|
|
||||||
export default function AcceptInvitePage() {
|
function AcceptInviteContent() {
|
||||||
const [state, setState] = useState<InviteState>('loading')
|
const [state, setState] = useState<InviteState>('loading')
|
||||||
const [errorType, setErrorType] = useState<string | null>(null)
|
const [errorType, setErrorType] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -214,3 +214,24 @@ export default function AcceptInvitePage() {
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loading fallback for Suspense
|
||||||
|
function LoadingCard() {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">Loading...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export with Suspense boundary for useSearchParams
|
||||||
|
export default function AcceptInvitePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingCard />}>
|
||||||
|
<AcceptInviteContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
|
|
@ -236,24 +237,29 @@ export function EvaluationFormBuilder({
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`weight-${criterion.id}`}>
|
<Label htmlFor={`weight-${criterion.id}`}>
|
||||||
Weight (optional)
|
Weight: {editDraft.weight ?? 1}x
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="flex items-center gap-3">
|
||||||
id={`weight-${criterion.id}`}
|
<span className="text-xs text-muted-foreground w-4">0.5</span>
|
||||||
type="number"
|
<Slider
|
||||||
min={0}
|
id={`weight-${criterion.id}`}
|
||||||
step={0.1}
|
min={0.5}
|
||||||
value={editDraft.weight ?? ''}
|
max={3}
|
||||||
onChange={(e) =>
|
step={0.5}
|
||||||
updateDraft({
|
value={[editDraft.weight ?? 1]}
|
||||||
weight: e.target.value
|
onValueChange={(v) => updateDraft({ weight: v[0] })}
|
||||||
? parseFloat(e.target.value)
|
disabled={disabled}
|
||||||
: undefined,
|
className="flex-1"
|
||||||
})
|
/>
|
||||||
}
|
<span className="text-xs text-muted-foreground w-4">3</span>
|
||||||
placeholder="1"
|
</div>
|
||||||
disabled={disabled}
|
<p className="text-xs text-muted-foreground">
|
||||||
/>
|
{(editDraft.weight ?? 1) === 1
|
||||||
|
? 'Normal importance'
|
||||||
|
: (editDraft.weight ?? 1) < 1
|
||||||
|
? 'Lower importance'
|
||||||
|
: 'Higher importance'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue