* feat: sync odds-based EV to participants table for draft room ranking When admin enters futures odds or manual probabilities, the calculated EV is now synced from participantExpectedValues to participants.expectedValue. This closes the gap where EVs were calculated but never reflected in the draft room ranking. Also changes expectedValue from integer to decimal(10,2) for better precision, and displays "—" for participants with no odds data. https://claude.ai/code/session_01JWG2zg2EMzCn6RgEufPC1T * fix: correct expectedValue type to string in participants route Missed instance of `expectedValue: 0` (number) that failed typecheck after schema change from integer to decimal. https://claude.ai/code/session_01JWG2zg2EMzCn6RgEufPC1T --------- Co-authored-by: Claude <noreply@anthropic.com>
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
import { Button } from "~/components/ui/button";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { ListPlus, ListX } from "lucide-react";
|
|
|
|
interface AvailableParticipantsSectionProps {
|
|
participants: Array<{
|
|
id: string;
|
|
name: string;
|
|
expectedValue: number;
|
|
sport: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
}>;
|
|
searchQuery: string;
|
|
sportFilter: string;
|
|
hideDrafted: boolean;
|
|
hideIneligible: boolean;
|
|
uniqueSports: string[];
|
|
draftedParticipantIds: Set<string>;
|
|
queue: Array<{ id: string; participantId: string }>;
|
|
eligibility: {
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, string>;
|
|
} | null;
|
|
canPick: boolean;
|
|
hasTeam: boolean;
|
|
onSearchChange: (query: string) => void;
|
|
onSportFilterChange: (sport: string) => void;
|
|
onHideDraftedChange: (hide: boolean) => void;
|
|
onHideIneligibleChange: (hide: boolean) => void;
|
|
onMakePick: (participantId: string) => void;
|
|
onAddToQueue: (participantId: string) => void;
|
|
onRemoveFromQueue: (queueId: string) => void;
|
|
}
|
|
|
|
export function AvailableParticipantsSection({
|
|
participants,
|
|
searchQuery,
|
|
sportFilter,
|
|
hideDrafted,
|
|
hideIneligible,
|
|
uniqueSports,
|
|
draftedParticipantIds,
|
|
queue,
|
|
eligibility,
|
|
canPick,
|
|
hasTeam,
|
|
onSearchChange,
|
|
onSportFilterChange,
|
|
onHideDraftedChange,
|
|
onHideIneligibleChange,
|
|
onMakePick,
|
|
onAddToQueue,
|
|
onRemoveFromQueue,
|
|
}: AvailableParticipantsSectionProps) {
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="px-4 pt-4 pb-2 flex-shrink-0">
|
|
<div className="space-y-2">
|
|
{/* Search Input */}
|
|
<input
|
|
type="text"
|
|
placeholder="Search participants..."
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-md text-sm"
|
|
/>
|
|
|
|
<div className="flex gap-3">
|
|
{/* Sport Filter Dropdown */}
|
|
<select
|
|
value={sportFilter}
|
|
onChange={(e) => onSportFilterChange(e.target.value)}
|
|
className="flex-1 px-3 py-2 border rounded-md text-sm"
|
|
>
|
|
<option value="all">All Sports</option>
|
|
{uniqueSports.map((sport) => (
|
|
<option key={sport} value={sport}>
|
|
{sport}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Show/Hide Drafted Toggle */}
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
|
<input
|
|
type="checkbox"
|
|
checked={!hideDrafted}
|
|
onChange={(e) => onHideDraftedChange(!e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<span>Show Drafted</span>
|
|
</label>
|
|
|
|
{/* Show/Hide Ineligible Toggle - only show if user has a team */}
|
|
{hasTeam && eligibility && (
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
|
<input
|
|
type="checkbox"
|
|
checked={!hideIneligible}
|
|
onChange={(e) => onHideIneligibleChange(!e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<span>Show Ineligible</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table - This will scroll independently */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<div className="h-full overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted sticky top-0">
|
|
<tr>
|
|
<th className="text-left p-3 font-semibold">Participant</th>
|
|
<th className="text-left p-3 font-semibold">Sport</th>
|
|
<th className="text-right p-3 font-semibold">EV</th>
|
|
{hasTeam && (
|
|
<th className="text-right p-3 font-semibold">Queue</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{participants.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={hasTeam ? 4 : 3}
|
|
className="text-center py-8 text-muted-foreground"
|
|
>
|
|
No participants found
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
participants.map((participant) => {
|
|
const isDrafted = draftedParticipantIds.has(participant.id);
|
|
const isInQueue = queue.some(
|
|
(item) => item.participantId === participant.id
|
|
);
|
|
|
|
// Check eligibility
|
|
const isEligible = eligibility
|
|
? eligibility.eligibleSportIds.has(participant.sport.id)
|
|
: true;
|
|
const ineligibleReason =
|
|
eligibility?.ineligibleReasons[participant.sport.id];
|
|
|
|
return (
|
|
<tr
|
|
key={participant.id}
|
|
className={`border-t transition-colors ${
|
|
isDrafted
|
|
? "bg-muted/50 opacity-60"
|
|
: !isEligible
|
|
? "bg-red-50/30 dark:bg-red-950/20 opacity-75"
|
|
: "hover:bg-muted/50"
|
|
}`}
|
|
title={
|
|
!isEligible && !isDrafted ? ineligibleReason : undefined
|
|
}
|
|
>
|
|
<td className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
|
>
|
|
{participant.name}
|
|
</span>
|
|
{isDrafted && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Drafted
|
|
</Badge>
|
|
)}
|
|
{!isDrafted && !isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
{isInQueue && !isDrafted && isEligible && (
|
|
<Badge variant="default" className="text-xs">
|
|
Queued
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="p-3 text-muted-foreground">
|
|
{participant.sport.name}
|
|
</td>
|
|
<td className="p-3 text-right font-mono font-semibold">
|
|
{participant.expectedValue ? participant.expectedValue.toFixed(1) : "—"}
|
|
</td>
|
|
{hasTeam && (
|
|
<td className="p-3 text-right">
|
|
<div className="flex gap-2 justify-end items-center">
|
|
{!isDrafted && (
|
|
<>
|
|
{/* Queue icon button */}
|
|
{!isInQueue ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onAddToQueue(participant.id)}
|
|
title={
|
|
!isEligible
|
|
? ineligibleReason
|
|
: "Add to queue"
|
|
}
|
|
disabled={!isEligible}
|
|
>
|
|
<ListPlus className="h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const queueItem = queue.find(
|
|
(item) =>
|
|
item.participantId === participant.id
|
|
);
|
|
if (queueItem) {
|
|
onRemoveFromQueue(queueItem.id);
|
|
}
|
|
}}
|
|
title="Remove from queue"
|
|
>
|
|
<ListX className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
{/* Draft button - always visible, disabled when not your turn */}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => onMakePick(participant.id)}
|
|
disabled={!canPick || !isEligible}
|
|
title={
|
|
!canPick
|
|
? "Not your turn"
|
|
: !isEligible
|
|
? ineligibleReason
|
|
: undefined
|
|
}
|
|
>
|
|
Draft
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|