Fix multiple UI/UX issues and invite token migration
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:
@@ -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,68 +355,145 @@ 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"
|
||||
>
|
||||
<Input
|
||||
placeholder="Full name"
|
||||
value={row.name}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'name', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={row.email}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'email', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={row.role}
|
||||
onValueChange={(v) =>
|
||||
updateRow(row.id, 'role', v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="JURY_MEMBER">
|
||||
Jury Member
|
||||
</SelectItem>
|
||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeRow(row.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<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}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'name', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={row.email}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, 'email', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={row.role}
|
||||
onValueChange={(v) =>
|
||||
updateRow(row.id, 'role', v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user