* Add commissioner time bank adjustment via right-click on draft board team headers Commissioners can right-click any team header on the Draft Board tab to open an "Adjust Time Bank..." dialog. The dialog supports adding or removing an arbitrary amount of time in seconds, minutes, or hours. The change is applied immediately to the database and broadcast to all clients via the existing timer-update Socket.IO event so every participant sees the updated clock in real time. - New API route: POST /api/draft/adjust-time-bank (commissioner-only) - DraftGridSection: wraps team headers in a ContextMenu for commissioners - Draft room: dialog state, handler, and updated DraftGridSection props https://claude.ai/code/session_013wxPKzLUCx3nC3LpxgjvQL * Fix code review issues in commissioner time bank adjustment - Wrap fetch in try/finally so isAdjustingTimeBank is always reset, even on network errors that cause fetch to throw - Guard against totalSeconds rounding to 0 for tiny fractional inputs - Reject API requests when the draft is not in 'draft' status (409) - Set input min to 0.001 so the browser rejects zero in native validation https://claude.ai/code/session_013wxPKzLUCx3nC3LpxgjvQL --------- Co-authored-by: Claude <noreply@anthropic.com>
265 lines
9.5 KiB
TypeScript
265 lines
9.5 KiB
TypeScript
import { useMemo } from "react";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
} from "~/components/ui/context-menu";
|
|
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
|
|
|
interface DraftGridSectionProps {
|
|
draftSlots: Array<{
|
|
id: string;
|
|
draftOrder: number;
|
|
team: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
}>;
|
|
draftGrid: Array<
|
|
Array<{
|
|
pickNumber: number;
|
|
round: number;
|
|
pickInRound: number;
|
|
teamId: string;
|
|
pick?: {
|
|
participant: {
|
|
name: string;
|
|
};
|
|
sport: {
|
|
name: string;
|
|
};
|
|
};
|
|
}>
|
|
>;
|
|
currentPick: number;
|
|
teamTimers: Record<string, number | undefined>;
|
|
autodraftStatus: Record<string, boolean>;
|
|
connectedTeams: Set<string>;
|
|
isCommissioner: boolean;
|
|
onAdjustTimeBankOpen?: (teamId: string) => void;
|
|
onForceAutopick: (pickNumber: number, teamId: string) => void;
|
|
onForceManualPickOpen: (pickNumber: number, teamId: string) => void;
|
|
onReplacePick?: (pickNumber: number, teamId: string) => void;
|
|
onRollbackToPick?: (pickNumber: number) => void;
|
|
}
|
|
|
|
export function DraftGridSection({
|
|
draftSlots,
|
|
draftGrid,
|
|
currentPick,
|
|
teamTimers,
|
|
autodraftStatus,
|
|
connectedTeams,
|
|
isCommissioner,
|
|
onAdjustTimeBankOpen,
|
|
onForceAutopick,
|
|
onForceManualPickOpen,
|
|
onReplacePick,
|
|
onRollbackToPick,
|
|
}: DraftGridSectionProps) {
|
|
const currentTeamId = useMemo(
|
|
() => draftGrid.flat().find((c) => c.pickNumber === currentPick)?.teamId ?? null,
|
|
[draftGrid, currentPick]
|
|
);
|
|
|
|
return (
|
|
<div className="h-full overflow-auto p-4">
|
|
<h2 className="text-xl font-semibold mb-4">Draft Grid</h2>
|
|
<div className="overflow-x-auto">
|
|
<div className="w-full">
|
|
{/* Team Headers */}
|
|
<div className="flex gap-2 mb-2">
|
|
{draftSlots.map((slot) => {
|
|
const teamTime = teamTimers[slot.team.id];
|
|
const isAutodraft = autodraftStatus[slot.team.id] || false;
|
|
const isConnected = connectedTeams.has(slot.team.id);
|
|
|
|
const headerInner = (
|
|
<>
|
|
<div
|
|
className={`font-semibold text-sm truncate px-2 ${
|
|
slot.team.id === currentTeamId
|
|
? "text-electric font-bold"
|
|
: !isConnected
|
|
? "italic text-muted-foreground"
|
|
: ""
|
|
}`}
|
|
>
|
|
{slot.team.name}
|
|
</div>
|
|
<div
|
|
className={`text-xs font-mono ${getTimerColorClass(teamTime)}`}
|
|
>
|
|
{formatClockTime(teamTime)}
|
|
{isAutodraft && (
|
|
<span className="ml-1 text-muted-foreground">
|
|
(auto)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
if (isCommissioner && onAdjustTimeBankOpen) {
|
|
return (
|
|
<ContextMenu key={slot.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<div className="flex-1 min-w-32 text-center cursor-context-menu">
|
|
{headerInner}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem
|
|
onClick={() => onAdjustTimeBankOpen(slot.team.id)}
|
|
>
|
|
Adjust Time Bank...
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={slot.id} className="flex-1 min-w-32 text-center">
|
|
{headerInner}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Draft Grid Rows */}
|
|
<div className="space-y-2">
|
|
{draftGrid.map((roundPicks, roundIndex) => {
|
|
const round = roundIndex + 1;
|
|
const isEvenRound = round % 2 === 0;
|
|
const displayPicks = isEvenRound
|
|
? [...roundPicks].reverse()
|
|
: roundPicks;
|
|
|
|
return (
|
|
<div key={roundIndex} className="flex gap-2">
|
|
{displayPicks.map((cell) => {
|
|
const isCurrent = cell.pickNumber === currentPick;
|
|
const isPicked = !!cell.pick;
|
|
|
|
const cellClassName = `flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
|
|
isCurrent
|
|
? "border-electric bg-electric/20 shadow-lg shadow-electric/25 ring-1 ring-electric/40"
|
|
: isPicked
|
|
? "bg-emerald-500/10 border-emerald-500/30"
|
|
: "border-border bg-card"
|
|
}`;
|
|
|
|
const cellInner = (
|
|
<>
|
|
<div className="text-xs font-mono text-muted-foreground mb-1">
|
|
{cell.round}.
|
|
{String(cell.pickInRound).padStart(2, "0")}
|
|
</div>
|
|
{isPicked ? (
|
|
<div className="text-xs">
|
|
<div className="font-semibold truncate">
|
|
{cell.pick?.participant.name}
|
|
</div>
|
|
<div className="text-muted-foreground truncate">
|
|
{cell.pick?.sport.name}
|
|
</div>
|
|
</div>
|
|
) : isCurrent ? (
|
|
<div className="text-sm font-bold text-electric animate-pulse">
|
|
On Clock
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
|
|
// Commissioner context menu on the current unpicked cell
|
|
if (isCommissioner && !isPicked && isCurrent) {
|
|
return (
|
|
<ContextMenu key={cell.pickNumber}>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className={cellClassName}
|
|
title={`Overall Pick #${cell.pickNumber}`}
|
|
>
|
|
{cellInner}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem
|
|
onClick={() =>
|
|
onForceAutopick(cell.pickNumber, cell.teamId)
|
|
}
|
|
>
|
|
Force Auto Pick
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() =>
|
|
onForceManualPickOpen(
|
|
cell.pickNumber,
|
|
cell.teamId
|
|
)
|
|
}
|
|
>
|
|
Force Manual Pick
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
// Commissioner context menu on already-picked cells
|
|
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
|
|
return (
|
|
<ContextMenu key={cell.pickNumber}>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className={cellClassName}
|
|
title={`Overall Pick #${cell.pickNumber}`}
|
|
>
|
|
{cellInner}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
{onReplacePick && (
|
|
<ContextMenuItem
|
|
onClick={() =>
|
|
onReplacePick(cell.pickNumber, cell.teamId)
|
|
}
|
|
>
|
|
Replace Pick
|
|
</ContextMenuItem>
|
|
)}
|
|
{onRollbackToPick && (
|
|
<ContextMenuItem
|
|
onClick={() => onRollbackToPick(cell.pickNumber)}
|
|
className="text-destructive focus:text-destructive"
|
|
>
|
|
Roll Back to This Pick
|
|
</ContextMenuItem>
|
|
)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={cell.pickNumber}
|
|
className={cellClassName}
|
|
title={`Overall Pick #${cell.pickNumber}`}
|
|
>
|
|
{cellInner}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|