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
|
||||
email: string
|
||||
role: Role
|
||||
expertiseTags: string[]
|
||||
tagInput: string
|
||||
}
|
||||
|
||||
interface ParsedUser {
|
||||
email: string
|
||||
name?: string
|
||||
role: Role
|
||||
expertiseTags?: string[]
|
||||
isValid: boolean
|
||||
error?: string
|
||||
isDuplicate?: boolean
|
||||
|
|
@ -78,15 +81,35 @@ function nextRowId(): string {
|
|||
}
|
||||
|
||||
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() {
|
||||
const [step, setStep] = useState<Step>('input')
|
||||
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
||||
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
|
||||
const [sendProgress, setSendProgress] = useState(0)
|
||||
const [result, setResult] = useState<{
|
||||
|
|
@ -97,7 +120,7 @@ export default function MemberInvitePage() {
|
|||
const bulkCreate = trpc.user.bulkCreate.useMutation()
|
||||
|
||||
// --- Manual entry helpers ---
|
||||
const updateRow = (id: string, field: keyof MemberRow, value: string) => {
|
||||
const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) => (r.id === id ? { ...r, [field]: value } : r))
|
||||
)
|
||||
|
|
@ -115,6 +138,38 @@ export default function MemberInvitePage() {
|
|||
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 ---
|
||||
const handleCSVUpload = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -190,6 +245,7 @@ export default function MemberInvitePage() {
|
|||
email,
|
||||
name: r.name.trim() || undefined,
|
||||
role: r.role,
|
||||
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
|
||||
isValid: isValidFormat && !isDuplicate,
|
||||
isDuplicate,
|
||||
error: !isValidFormat
|
||||
|
|
@ -208,17 +264,6 @@ export default function MemberInvitePage() {
|
|||
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 ---
|
||||
const summary = useMemo(() => {
|
||||
const validUsers = parsedUsers.filter((u) => u.isValid)
|
||||
|
|
@ -246,8 +291,7 @@ export default function MemberInvitePage() {
|
|||
email: u.email,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
expertiseTags:
|
||||
expertiseTags.length > 0 ? expertiseTags : undefined,
|
||||
expertiseTags: u.expertiseTags,
|
||||
})),
|
||||
})
|
||||
setSendProgress(100)
|
||||
|
|
@ -311,27 +355,29 @@ export default function MemberInvitePage() {
|
|||
</div>
|
||||
|
||||
{inputMethod === 'manual' ? (
|
||||
<div className="space-y-3">
|
||||
{/* Column headers */}
|
||||
<div className="hidden sm:grid sm:grid-cols-[1fr_1fr_140px_36px] gap-2 px-1">
|
||||
<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 className="space-y-4">
|
||||
{/* Member cards */}
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid gap-2 sm:grid-cols-[1fr_1fr_140px_36px]"
|
||||
className="rounded-lg border p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Member {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeRow(row.id)}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_140px]">
|
||||
<Input
|
||||
placeholder="Full name"
|
||||
value={row.name}
|
||||
|
|
@ -364,15 +410,90 @@ export default function MemberInvitePage() {
|
|||
<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="icon"
|
||||
onClick={() => removeRow(row.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => addTagToRow(row.id, suggestion)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
+ {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>
|
||||
))}
|
||||
|
||||
|
|
@ -417,44 +538,6 @@ export default function MemberInvitePage() {
|
|||
</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 */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" asChild>
|
||||
|
|
|
|||
|
|
@ -90,9 +90,14 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
roundId,
|
||||
})
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Mutations
|
||||
const updateRound = trpc.round.update.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate cache to ensure fresh data
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
utils.round.list.invalidate()
|
||||
router.push(`/admin/rounds/${roundId}`)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -16,7 +16,7 @@ import { trpc } from '@/lib/trpc/client'
|
|||
|
||||
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
function AcceptInviteContent() {
|
||||
const [state, setState] = useState<InviteState>('loading')
|
||||
const [errorType, setErrorType] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -214,3 +214,24 @@ export default function AcceptInvitePage() {
|
|||
</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 { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import {
|
||||
|
|
@ -236,24 +237,29 @@ export function EvaluationFormBuilder({
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`weight-${criterion.id}`}>
|
||||
Weight (optional)
|
||||
Weight: {editDraft.weight ?? 1}x
|
||||
</Label>
|
||||
<Input
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-4">0.5</span>
|
||||
<Slider
|
||||
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"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.5}
|
||||
value={[editDraft.weight ?? 1]}
|
||||
onValueChange={(v) => updateDraft({ weight: v[0] })}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-4">3</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(editDraft.weight ?? 1) === 1
|
||||
? 'Normal importance'
|
||||
: (editDraft.weight ?? 1) < 1
|
||||
? 'Lower importance'
|
||||
: 'Higher importance'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
Loading…
Reference in New Issue