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>
406 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|