165 lines
5.3 KiB
TypeScript
165 lines
5.3 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState, useRef, useEffect } from 'react'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Loader2, Pencil } from 'lucide-react'
|
||
|
|
|
||
|
|
export type InlineMemberCapProps = {
|
||
|
|
memberId: string
|
||
|
|
currentValue: number | null
|
||
|
|
onSave: (val: number | null) => void
|
||
|
|
roundId?: string
|
||
|
|
jurorUserId?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export function InlineMemberCap({
|
||
|
|
memberId,
|
||
|
|
currentValue,
|
||
|
|
onSave,
|
||
|
|
roundId,
|
||
|
|
jurorUserId,
|
||
|
|
}: InlineMemberCapProps) {
|
||
|
|
const utils = trpc.useUtils()
|
||
|
|
const [editing, setEditing] = useState(false)
|
||
|
|
const [value, setValue] = useState(currentValue?.toString() ?? '')
|
||
|
|
const [overCapInfo, setOverCapInfo] = useState<{ total: number; overCapCount: number; movableOverCap: number; immovableOverCap: number } | null>(null)
|
||
|
|
const [showBanner, setShowBanner] = useState(false)
|
||
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
||
|
|
|
||
|
|
const redistributeMutation = trpc.assignment.redistributeOverCap.useMutation({
|
||
|
|
onSuccess: (data) => {
|
||
|
|
utils.assignment.listByStage.invalidate()
|
||
|
|
utils.analytics.getJurorWorkload.invalidate()
|
||
|
|
utils.roundAssignment.unassignedQueue.invalidate()
|
||
|
|
setShowBanner(false)
|
||
|
|
setOverCapInfo(null)
|
||
|
|
if (data.failed > 0) {
|
||
|
|
toast.warning(`Redistributed ${data.redistributed} project(s). ${data.failed} could not be reassigned.`)
|
||
|
|
} else {
|
||
|
|
toast.success(`Redistributed ${data.redistributed} project(s) to other jurors.`)
|
||
|
|
}
|
||
|
|
},
|
||
|
|
onError: (err) => toast.error(err.message),
|
||
|
|
})
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (editing) inputRef.current?.focus()
|
||
|
|
}, [editing])
|
||
|
|
|
||
|
|
const save = async () => {
|
||
|
|
const trimmed = value.trim()
|
||
|
|
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
|
||
|
|
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
|
||
|
|
toast.error('Enter a positive number or leave empty for no cap')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (newVal === currentValue) {
|
||
|
|
setEditing(false)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check over-cap impact before saving
|
||
|
|
if (newVal !== null && roundId && jurorUserId) {
|
||
|
|
try {
|
||
|
|
const preview = await utils.client.assignment.getOverCapPreview.query({
|
||
|
|
roundId,
|
||
|
|
jurorId: jurorUserId,
|
||
|
|
newCap: newVal,
|
||
|
|
})
|
||
|
|
if (preview.overCapCount > 0) {
|
||
|
|
setOverCapInfo(preview)
|
||
|
|
setShowBanner(true)
|
||
|
|
setEditing(false)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// If preview fails, just save the cap normally
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onSave(newVal)
|
||
|
|
setEditing(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleRedistribute = () => {
|
||
|
|
const newVal = parseInt(value.trim(), 10)
|
||
|
|
onSave(newVal)
|
||
|
|
if (roundId && jurorUserId) {
|
||
|
|
redistributeMutation.mutate({ roundId, jurorId: jurorUserId, newCap: newVal })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleJustSave = () => {
|
||
|
|
const newVal = value.trim() === '' ? null : parseInt(value.trim(), 10)
|
||
|
|
onSave(newVal)
|
||
|
|
setShowBanner(false)
|
||
|
|
setOverCapInfo(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (showBanner && overCapInfo) {
|
||
|
|
return (
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
|
||
|
|
<p>New cap of <strong>{value}</strong> is below current load (<strong>{overCapInfo.total}</strong> assignments). <strong>{overCapInfo.movableOverCap}</strong> can be redistributed.</p>
|
||
|
|
{overCapInfo.immovableOverCap > 0 && (
|
||
|
|
<p className="text-amber-600 mt-0.5">{overCapInfo.immovableOverCap} submitted evaluation(s) cannot be moved.</p>
|
||
|
|
)}
|
||
|
|
<div className="flex gap-1.5 mt-1.5">
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="default"
|
||
|
|
className="h-6 text-xs px-2"
|
||
|
|
disabled={redistributeMutation.isPending || overCapInfo.movableOverCap === 0}
|
||
|
|
onClick={handleRedistribute}
|
||
|
|
>
|
||
|
|
{redistributeMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||
|
|
Redistribute
|
||
|
|
</Button>
|
||
|
|
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={handleJustSave}>
|
||
|
|
Just save cap
|
||
|
|
</Button>
|
||
|
|
<Button size="sm" variant="ghost" className="h-6 text-xs px-2" onClick={() => { setShowBanner(false); setOverCapInfo(null) }}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (editing) {
|
||
|
|
return (
|
||
|
|
<Input
|
||
|
|
ref={inputRef}
|
||
|
|
type="number"
|
||
|
|
min={1}
|
||
|
|
className="h-6 w-16 text-xs"
|
||
|
|
value={value}
|
||
|
|
placeholder="\u221E"
|
||
|
|
onChange={(e) => setValue(e.target.value)}
|
||
|
|
onBlur={save}
|
||
|
|
onKeyDown={(e) => {
|
||
|
|
if (e.key === 'Enter') save()
|
||
|
|
if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs hover:bg-muted transition-colors group"
|
||
|
|
title="Click to set max assignment cap"
|
||
|
|
onClick={() => { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
|
||
|
|
>
|
||
|
|
<span className="text-muted-foreground">max:</span>
|
||
|
|
<span className="font-medium">{currentValue ?? '\u221E'}</span>
|
||
|
|
<Pencil className="h-2.5 w-2.5 text-muted-foreground" />
|
||
|
|
</button>
|
||
|
|
)
|
||
|
|
}
|