290 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|