brackt/app/components/BatchResultEntry.tsx
Chris Parsons cda578d0ac
feat(BatchResultEntry): make form intent configurable
Canonical tournament admin page will submit with a different intent
(batch-upsert-results). Existing per-window callers continue to get
"batch-add-results" as the default, so no behavior changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 21:27:42 +00:00

406 lines
14 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { useFetcher, useRevalidator } from "react-router";
import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
import { Input } from "~/components/ui/input";
import { Badge } from "~/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { CheckCircle2, Trophy } from "lucide-react";
import { parseResultsText } from "~/lib/parse-results-text";
interface Participant {
id: string;
name: string;
}
interface ResolvedRow {
placement: number;
rawName: string;
participantId: string | null; // null = unresolved
}
type Stage = "idle" | "preview" | "saving";
interface BatchResultEntryProps {
participants: Participant[];
sportsSeasonId: string;
existingResultParticipantIds: Set<string>;
/**
* Form intent name posted on "Save All". Defaults to "batch-add-results" for
* the existing per-window flow. Canonical tournament pages override this.
*/
intent?: string;
}
export function BatchResultEntry({
participants,
sportsSeasonId,
existingResultParticipantIds,
intent = "batch-add-results",
}: BatchResultEntryProps) {
const [stage, setStage] = useState<Stage>("idle");
const [pasteText, setPasteText] = useState("");
const [rows, setRows] = useState<ResolvedRow[]>([]);
const [parseError, setParseError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [addingNewFor, setAddingNewFor] = useState<number | null>(null);
const [newParticipantName, setNewParticipantName] = useState("");
const { revalidate } = useRevalidator();
const saveFetcher = useFetcher();
const createParticipantFetcher = useFetcher();
// Mutable lookup built from the prop — updated when new participants are created
const participantByNameRef = useRef(
new Map(participants.map((p) => [p.name.toLowerCase(), p]))
);
// Mutable list — grows when new participants are created inline
const participantsRef = useRef([...participants]);
const pendingNewParticipantRowRef = useRef<number | null>(null);
const createData = createParticipantFetcher.data as
| { newParticipant?: { id: string; name: string }; error?: string }
| undefined;
const saveData = saveFetcher.data as
| { success?: string; error?: string }
| undefined;
// When createParticipantFetcher returns a new participant, auto-select it
useEffect(() => {
if (
createData?.newParticipant &&
pendingNewParticipantRowRef.current !== null
) {
const newP = createData.newParticipant;
const rowIndex = pendingNewParticipantRowRef.current;
pendingNewParticipantRowRef.current = null;
// Update lookup and list
participantByNameRef.current.set(newP.name.toLowerCase(), newP);
participantsRef.current.push(newP);
// Auto-select for the row
setRows((prev) =>
prev.map((row, i) =>
i === rowIndex ? { ...row, participantId: newP.id } : row
)
);
}
}, [createData]);
// Watch save fetcher for completion
useEffect(() => {
if (stage === "saving" && saveFetcher.state === "idle" && saveData) {
if (saveData.success) {
setSuccessMessage(saveData.success);
setStage("idle");
setPasteText("");
setRows([]);
revalidate();
} else if (saveData.error) {
setStage("preview");
}
}
}, [saveFetcher.state, saveData, stage, revalidate]);
function handleParse() {
setParseError(null);
const parsed = parseResultsText(pasteText);
if (parsed.length === 0) {
setParseError("No results found. Check the format and try again.");
return;
}
const resolved: ResolvedRow[] = parsed.map((line) => {
const match = participantByNameRef.current.get(line.rawName.toLowerCase());
return {
placement: line.placement,
rawName: line.rawName,
participantId: match ? match.id : null,
};
});
setRows(resolved);
setStage("preview");
}
function setRowParticipant(index: number, participantId: string) {
setRows((prev) =>
prev.map((row, i) => (i === index ? { ...row, participantId } : row))
);
}
function handleAddNewParticipant(index: number) {
setAddingNewFor(index);
setNewParticipantName(rows[index].rawName);
}
function handleCreateParticipant(index: number) {
if (!newParticipantName.trim()) return;
const formData = new FormData();
formData.set("intent", "create-participant");
formData.set("name", newParticipantName.trim());
formData.set("sportsSeasonId", sportsSeasonId);
createParticipantFetcher.submit(formData, { method: "post" });
pendingNewParticipantRowRef.current = index;
setAddingNewFor(null);
setNewParticipantName("");
}
function handleConfirm() {
if (!allResolved || rows.length === 0) return;
setStage("saving");
const results = rows
.filter((r) => r.participantId !== null)
.map((r) => ({
participantId: r.participantId as string,
placement: r.placement,
}));
const formData = new FormData();
formData.set("intent", intent);
formData.set("results", JSON.stringify(results));
saveFetcher.submit(formData, { method: "post" });
}
const allResolved = rows.every((r) => r.participantId !== null);
if (successMessage) {
return (
<Card className="border-emerald-500/30">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-emerald-500">
<CheckCircle2 className="h-4 w-4" />
<span className="text-sm font-medium">{successMessage}</span>
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => setSuccessMessage(null)}
>
Import more
</Button>
</div>
</CardContent>
</Card>
);
}
// Participants available for mapping — exclude those already in saved results
// (but include any that are currently resolved in this batch, to allow re-use)
const resolvedIds = new Set(rows.map((r) => r.participantId).filter(Boolean) as string[]);
const availableParticipants = participantsRef.current.filter(
(p) => !existingResultParticipantIds.has(p.id) || resolvedIds.has(p.id)
);
return (
<Card>
<CardHeader>
<CardTitle>
<Trophy className="inline mr-2 h-4 w-4" />
Batch Paste Results
</CardTitle>
<CardDescription>
Paste a ranked results list to import all placements at once. Format:{" "}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
1. Player Name
</code>{" "}
use{" "}
<code className="text-xs bg-muted px-1 py-0.5 rounded">T3</code> or
repeat a rank for ties.
</CardDescription>
</CardHeader>
<CardContent>
{stage === "idle" && (
<div className="space-y-4">
<Textarea
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
placeholder={
"1. Gerwyn Price\n2. Luke Littler\nT3. Michael van Gerwen\nT3. Peter Wright\n5. Damon Heta"
}
rows={10}
className="font-mono text-sm"
/>
{parseError && (
<p className="text-sm text-destructive">{parseError}</p>
)}
<Button
onClick={handleParse}
disabled={!pasteText.trim()}
className="w-full"
>
Parse Results
</Button>
</div>
)}
{(stage === "preview" || stage === "saving") && (
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">Placement</TableHead>
<TableHead>Parsed Name</TableHead>
<TableHead>Matched Participant</TableHead>
<TableHead className="w-20">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, index) => {
const isTied =
rows.filter((r) => r.placement === row.placement).length >
1;
return (
<TableRow key={`${row.placement}-${row.rawName}`}>
<TableCell>
<div className="flex items-center gap-1">
<span className="font-semibold">{row.placement}</span>
{isTied && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
T
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{row.rawName}
</TableCell>
<TableCell>
{row.participantId ? (
<span className="text-sm text-emerald-600 dark:text-emerald-400 font-medium">
{participantsRef.current.find(
(p) => p.id === row.participantId
)?.name ?? row.rawName}
</span>
) : addingNewFor === index ? (
<div className="flex items-center gap-2">
<Input
value={newParticipantName}
onChange={(e) =>
setNewParticipantName(e.target.value)
}
className="h-8 text-sm"
placeholder="Participant name"
/>
<Button
size="sm"
className="h-8"
onClick={() => handleCreateParticipant(index)}
disabled={!newParticipantName.trim() || createParticipantFetcher.state !== "idle"}
>
Add
</Button>
<Button
size="sm"
variant="ghost"
className="h-8"
onClick={() => setAddingNewFor(null)}
>
Cancel
</Button>
</div>
) : (
<Select
onValueChange={(val) => {
if (val === "__add_new__") {
handleAddNewParticipant(index);
} else {
setRowParticipant(index, val);
}
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select participant…" />
</SelectTrigger>
<SelectContent>
{availableParticipants.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
<SelectItem value="__add_new__">
+ Add new participant
</SelectItem>
</SelectContent>
</Select>
)}
</TableCell>
<TableCell>
{row.participantId ? (
<Badge className="bg-emerald-500 text-white text-xs">
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
!
</Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{saveData?.error && (
<p className="text-sm text-destructive">{saveData.error}</p>
)}
{createData?.error && (
<p className="text-sm text-destructive">{createData.error}</p>
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setStage("idle")}
disabled={stage === "saving"}
>
Back
</Button>
<Button
onClick={handleConfirm}
disabled={!allResolved || stage === "saving"}
className="flex-1"
>
{stage === "saving" ? (
"Saving…"
) : (
<>
<CheckCircle2 className="mr-2 h-4 w-4" />
Confirm & Save All ({rows.length} result
{rows.length === 1 ? "" : "s"})
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}