Closes #72 Upgrades ineligibleReasons from Record<string, string> to a structured {code, message} type so the UI can render the human-readable reason inline below the Ineligible badge in both AvailableParticipantsSection and ParticipantSelectionDialog. API routes surface the message in 400 responses unchanged. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
194 lines
6.8 KiB
TypeScript
194 lines
6.8 KiB
TypeScript
import { useState, useEffect, useMemo } from "react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "~/components/ui/dialog";
|
|
|
|
interface ParticipantSelectionDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
title: string;
|
|
description: string;
|
|
participants: Array<{
|
|
id: string;
|
|
name: string;
|
|
sport: { id: string; name: string };
|
|
}>;
|
|
draftedParticipantIds: Set<string>;
|
|
/** Pass the old participant's ID to keep them in the list (replace-pick dialog) */
|
|
allowParticipantId?: string;
|
|
eligibility: {
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, DraftIneligibilityReason>;
|
|
} | null;
|
|
/** Marks a row with "Current" badge and "Keep" button label */
|
|
currentParticipantId?: string;
|
|
uniqueSports: string[];
|
|
onSelect: (participantId: string) => void;
|
|
}
|
|
|
|
export function ParticipantSelectionDialog({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
description,
|
|
participants,
|
|
draftedParticipantIds,
|
|
allowParticipantId,
|
|
eligibility,
|
|
currentParticipantId,
|
|
uniqueSports,
|
|
onSelect,
|
|
}: ParticipantSelectionDialogProps) {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [sportFilter, setSportFilter] = useState("all");
|
|
|
|
// Reset filters each time the dialog opens
|
|
useEffect(() => {
|
|
if (open) {
|
|
setSearchQuery("");
|
|
setSportFilter("all");
|
|
}
|
|
}, [open]);
|
|
|
|
const filtered = useMemo(
|
|
() =>
|
|
participants.filter((p) => {
|
|
if (p.id !== allowParticipantId && draftedParticipantIds.has(p.id))
|
|
return false;
|
|
if (
|
|
searchQuery &&
|
|
!p.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
return false;
|
|
if (sportFilter !== "all" && p.sport.name !== sportFilter) return false;
|
|
return true;
|
|
}),
|
|
[participants, draftedParticipantIds, allowParticipantId, searchQuery, sportFilter]
|
|
);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
|
|
<DialogHeader className="p-6 pb-4">
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription>{description}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="px-6 pb-4">
|
|
<div className="flex gap-2 mb-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Search participants..."
|
|
className="flex-1 px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
<select
|
|
className="px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
|
|
value={sportFilter}
|
|
onChange={(e) => setSportFilter(e.target.value)}
|
|
>
|
|
<option value="all">All Sports</option>
|
|
{uniqueSports.map((sport) => (
|
|
<option key={sport} value={sport}>
|
|
{sport}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="max-h-96 overflow-y-auto border rounded-lg">
|
|
<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">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map((participant) => {
|
|
const isCurrentPick =
|
|
participant.id === currentParticipantId;
|
|
const isEligible = eligibility
|
|
? eligibility.eligibleSportIds.has(participant.sport.id)
|
|
: true;
|
|
const ineligibleReason =
|
|
eligibility?.ineligibleReasons[participant.sport.id] ?? null;
|
|
|
|
return (
|
|
<tr
|
|
key={participant.id}
|
|
className={`border-t ${
|
|
isEligible
|
|
? "hover:bg-muted/50"
|
|
: "bg-destructive/10 opacity-60"
|
|
}`}
|
|
title={!isEligible ? ineligibleReason?.message : undefined}
|
|
>
|
|
<td className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}
|
|
>
|
|
{participant.name}
|
|
</span>
|
|
{isCurrentPick && (
|
|
<Badge variant="outline" className="text-xs">
|
|
Current
|
|
</Badge>
|
|
)}
|
|
{!isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason?.message}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{!isEligible && ineligibleReason && (
|
|
<p className="mt-1 text-xs text-destructive/90">
|
|
{ineligibleReason.message}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="p-3 text-muted-foreground">
|
|
{participant.sport.name}
|
|
</td>
|
|
<td className="p-3 text-right">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => onSelect(participant.id)}
|
|
disabled={!isEligible}
|
|
title={!isEligible ? ineligibleReason?.message : undefined}
|
|
>
|
|
{isCurrentPick ? "Keep" : "Draft"}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="px-6 pb-6">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|