Files
monorepo/baseball/src/components/LineupClient.tsx
T
2026-05-06 15:33:36 +00:00

290 lines
12 KiB
TypeScript

"use client";
import { Alert, Box, Button, Card, CardContent, Checkbox, Divider, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { EmptyState } from "@/components/EmptyState";
import { SectionCard } from "@/components/SectionCard";
import { StatusBadge } from "@/components/StatusBadge";
import { FIELD_POSITIONS } from "@/lib/constants";
import { fetchJson } from "@/lib/client";
import { formatPlayerName } from "@/lib/utils";
import type { GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
export function LineupClient() {
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
const [players, setPlayers] = useState<PlayerRecord[]>([]);
const [selectedPlayerIds, setSelectedPlayerIds] = useState<string[]>([]);
const [startingPitcherId, setStartingPitcherId] = useState<string>("");
const [showAllPitchers, setShowAllPitchers] = useState(false);
const [generated, setGenerated] = useState<GeneratedLineup | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function loadPlayers(targetDate: string) {
try {
const response = await fetchJson<PlayerRecord[]>(`/api/players?gameDate=${targetDate}`);
setPlayers(response);
const activeIds = response.filter((player) => player.isActive).map((player) => player._id);
setSelectedPlayerIds(activeIds);
const firstEligible = response.find((player) => player.pitcherAvailability.status === "available");
setStartingPitcherId(firstEligible?._id ?? response[0]?._id ?? "");
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load players.");
}
}
useEffect(() => {
void loadPlayers(gameDate);
}, [gameDate]);
const selectablePitchers = useMemo(
() =>
players.filter((player) =>
selectedPlayerIds.includes(player._id) &&
(showAllPitchers || player.pitcherAvailability.status !== "needs-rest")
),
[players, selectedPlayerIds, showAllPitchers]
);
const selectedPlayers = useMemo(
() => players.filter((player) => selectedPlayerIds.includes(player._id)),
[players, selectedPlayerIds]
);
function updateGenerated(next: GeneratedLineup | null) {
setGenerated(next);
}
async function generate() {
try {
const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", {
method: "POST",
body: JSON.stringify({
gameDate,
playerIds: selectedPlayerIds,
startingPitcherId: startingPitcherId || null
})
});
setGenerated(response);
setMessage("Suggested lineup generated.");
} catch (generateError) {
setError(generateError instanceof Error ? generateError.message : "Unable to generate lineup.");
}
}
async function save() {
if (!generated) return;
try {
await fetchJson<LineupRecord>("/api/lineups", {
method: "POST",
body: JSON.stringify({
...generated,
playerIds: selectedPlayerIds
})
});
setMessage("Lineup saved.");
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Unable to save lineup.");
}
}
return (
<AppShell
title="Generate Lineup"
subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed."
>
{message ? <Alert severity="success">{message}</Alert> : null}
{error ? <Alert severity="error">{error}</Alert> : null}
<SectionCard title="Game Setup" description="Start with the players who are available today.">
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
<TextField
label="Game Date"
type="date"
value={gameDate}
onChange={(event) => setGameDate(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField select label="Starting Pitcher" value={startingPitcherId} onChange={(event) => setStartingPitcherId(event.target.value)}>
{selectablePitchers.map((player) => (
<MenuItem key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</MenuItem>
))}
</TextField>
<Box sx={{ gridColumn: "1 / -1" }}>
<FormControlLabel
control={<Checkbox checked={showAllPitchers} onChange={(event) => setShowAllPitchers(event.target.checked)} />}
label="Show pitchers who need rest"
/>
</Box>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.25} sx={{ gridColumn: "1 / -1" }}>
<Button variant="contained" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
Generate Suggested Lineup
</Button>
<Button variant="outlined" color="inherit" disabled={!generated} onClick={() => void save()}>
Save Lineup
</Button>
</Stack>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(2, 1fr)" }, gap: 1.5, mt: 3 }}>
{players.map((player) => (
<Card
key={player._id}
elevation={0}
sx={{
border: selectedPlayerIds.includes(player._id) ? "1px solid" : "1px solid rgba(15, 38, 47, 0.08)",
borderColor: selectedPlayerIds.includes(player._id) ? "primary.main" : "rgba(15, 38, 47, 0.08)",
backgroundColor: selectedPlayerIds.includes(player._id) ? "rgba(15, 92, 77, 0.05)" : "transparent"
}}
>
<CardContent
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
justifyContent: "space-between",
alignItems: { xs: "flex-start", sm: "center" },
gap: 1.5
}}
>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "flex-start" }}>
<Checkbox
checked={selectedPlayerIds.includes(player._id)}
onChange={(event) =>
setSelectedPlayerIds((current) =>
event.target.checked ? [...current, player._id] : current.filter((id) => id !== player._id)
)
}
/>
<Box>
<Typography variant="h6">{formatPlayerName(player.firstName, player.lastName)}</Typography>
<Typography color="text.secondary">
#{player.jerseyNumber} {player.primaryPosition}
</Typography>
</Box>
</Stack>
<StatusBadge status={player.pitcherAvailability.status} />
</CardContent>
</Card>
))}
</Box>
</SectionCard>
{generated ? (
<>
<SectionCard title="Batting Order" description="Adjust any slot manually before saving.">
<Stack divider={<Divider flexItem />} spacing={0}>
{generated.battingOrder.map((slot, index) => (
<Box
key={`${slot.playerId}-${index}`}
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "180px 1fr" },
alignItems: "center",
gap: 2,
py: 2
}}
>
<Typography variant="h6">Spot {index + 1}</Typography>
<TextField
select
value={slot.playerId}
onChange={(event) => {
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
if (!nextPlayer) return;
updateGenerated({
...generated,
battingOrder: generated.battingOrder.map((entry, entryIndex) =>
entryIndex === index
? {
...entry,
playerId: nextPlayer._id,
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName)
}
: entry
)
});
}}
>
{selectedPlayers.map((player) => (
<MenuItem key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</MenuItem>
))}
</TextField>
</Box>
))}
</Stack>
</SectionCard>
<SectionCard title="Defensive Alignment" description="Assignments follow position preferences, but every field is editable.">
<Stack divider={<Divider flexItem />} spacing={0}>
{FIELD_POSITIONS.map((position) => {
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
return (
<Box
key={position}
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "180px 1fr" },
alignItems: "center",
gap: 2,
py: 2
}}
>
<Typography variant="h6">{position}</Typography>
<TextField
select
value={assignment?.playerId ?? ""}
onChange={(event) => {
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
if (!nextPlayer) return;
const nextAssignments = generated.defensiveAssignments.filter((entry) => entry.position !== position);
nextAssignments.push({
playerId: nextPlayer._id,
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
position
});
updateGenerated({
...generated,
defensiveAssignments: nextAssignments
});
}}
>
<MenuItem value="">Unassigned</MenuItem>
{selectedPlayers.map((player) => (
<MenuItem key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</MenuItem>
))}
</TextField>
</Box>
);
})}
</Stack>
{generated.notes.length > 0 ? (
<Box sx={{ mt: 2, pl: 2, borderLeft: "4px solid", borderColor: "warning.main" }}>
{generated.notes.map((note) => (
<Typography key={note} color="text.secondary">
{note}
</Typography>
))}
</Box>
) : null}
</SectionCard>
</>
) : (
<SectionCard title="No Lineup Yet" description="Generate a lineup once your game-day roster is selected.">
<EmptyState
title="Waiting on lineup"
description="Select your active players, choose a starter, and generate a suggested batting order and field setup."
/>
</SectionCard>
)}
</AppShell>
);
}