feat: add BatchResultEntry component for paste-and-parse batch result import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3741bcf1ce
commit
48dca7f072
1 changed files with 402 additions and 0 deletions
402
app/components/BatchResultEntry.tsx
Normal file
402
app/components/BatchResultEntry.tsx
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
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[];
|
||||
eventId: string;
|
||||
sportsSeasonId: string;
|
||||
existingResultParticipantIds: Set<string>;
|
||||
}
|
||||
|
||||
export function BatchResultEntry({
|
||||
participants,
|
||||
eventId: _eventId,
|
||||
sportsSeasonId,
|
||||
existingResultParticipantIds,
|
||||
}: 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) 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", "batch-add-results");
|
||||
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()}
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue