This commit is contained in:
Zeeshaun
2026-05-06 15:33:36 +00:00
parent 0de593c7d3
commit 9e56dae161
21 changed files with 1663 additions and 955 deletions
+88 -20
View File
@@ -1,9 +1,9 @@
"use client";
import SportsBaseballRoundedIcon from "@mui/icons-material/SportsBaseballRounded";
import { Box, Chip, Container, Paper, Tab, Tabs, Typography } from "@mui/material";
import Link from "next/link";
import type { Route } from "next";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const navItems = [
{ href: "/dashboard", label: "Dashboard" },
@@ -24,27 +24,95 @@ export function AppShell({
actions?: React.ReactNode;
}) {
const pathname = usePathname();
const currentTab = navItems.find((item) => pathname === item.href)?.href ?? false;
return (
<div className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">Little League Coach Console</p>
<h1>{title}</h1>
<p className="hero-copy">{subtitle}</p>
</div>
{actions ? <div className="hero-actions">{actions}</div> : null}
</header>
<Container maxWidth="lg" sx={{ py: { xs: 2, md: 4 } }}>
<Paper
elevation={0}
sx={{
p: { xs: 3, md: 4 },
borderRadius: 7,
color: "common.white",
background:
"linear-gradient(135deg, rgba(9,45,64,0.98) 0%, rgba(15,92,77,0.96) 50%, rgba(207,111,46,0.92) 100%)",
boxShadow: "0 24px 64px rgba(16, 41, 52, 0.24)"
}}
>
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
justifyContent: "space-between",
gap: 3
}}
>
<Box sx={{ maxWidth: 760 }}>
<Chip
icon={<SportsBaseballRoundedIcon />}
label="Little League Coach Console"
sx={{
mb: 2,
color: "common.white",
bgcolor: "rgba(255,255,255,0.14)",
"& .MuiChip-icon": { color: "common.white" }
}}
/>
<Typography variant="h1" sx={{ fontSize: { xs: "2.6rem", md: "4rem" }, lineHeight: 0.95 }}>
{title}
</Typography>
<Typography sx={{ mt: 1.5, maxWidth: 620, color: "rgba(255,255,255,0.84)" }}>{subtitle}</Typography>
</Box>
{actions ? <Box sx={{ display: "flex", alignItems: "flex-start" }}>{actions}</Box> : null}
</Box>
</Paper>
<nav className="tabs" aria-label="Primary">
{navItems.map((item) => (
<Link key={item.href} href={item.href as Route} className={cn("tab", pathname === item.href && "tab-active")}>
{item.label}
</Link>
))}
</nav>
<Paper
elevation={0}
sx={{
mt: 2,
p: 1,
borderRadius: 5,
border: "1px solid rgba(15, 38, 47, 0.08)",
backgroundColor: "rgba(255,255,255,0.72)",
backdropFilter: "blur(10px)"
}}
>
<Tabs
value={currentTab}
variant="scrollable"
scrollButtons="auto"
aria-label="Primary"
sx={{
minHeight: 0,
"& .MuiTabs-indicator": { display: "none" }
}}
>
{navItems.map((item) => (
<Tab
key={item.href}
component={Link}
href={item.href}
value={item.href}
label={item.label}
sx={{
minHeight: 0,
borderRadius: 999,
color: "text.secondary",
"&.Mui-selected": {
color: "text.primary",
bgcolor: "background.paper",
boxShadow: "0 10px 24px rgba(16, 41, 52, 0.08)"
}
}}
/>
))}
</Tabs>
</Paper>
<main className="page-grid">{children}</main>
</div>
<Box component="main" sx={{ display: "grid", gap: 2.25, mt: 2.5 }}>
{children}
</Box>
</Container>
);
}
@@ -0,0 +1,96 @@
"use client";
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
const theme = createTheme({
palette: {
mode: "light",
primary: {
main: "#0f5c4d",
light: "#2d7a69",
dark: "#0a4338"
},
secondary: {
main: "#cf6f2e",
light: "#e38a4a",
dark: "#a55720"
},
background: {
default: "#f6f1e7",
paper: "#fffdf8"
},
success: {
main: "#1f7a52"
},
warning: {
main: "#ba7b11"
},
error: {
main: "#ba4637"
}
},
shape: {
borderRadius: 18
},
typography: {
fontFamily: "var(--font-sans)",
h1: {
fontFamily: "var(--font-display)",
fontWeight: 700,
letterSpacing: "-0.03em"
},
h2: {
fontFamily: "var(--font-display)",
fontWeight: 700,
letterSpacing: "-0.02em"
},
h3: {
fontWeight: 700
},
button: {
fontWeight: 700,
textTransform: "none"
}
},
components: {
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: "none"
}
}
},
MuiButton: {
defaultProps: {
disableElevation: true
},
styleOverrides: {
root: {
borderRadius: 999,
paddingInline: 18
}
}
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 22
}
}
},
MuiTextField: {
defaultProps: {
size: "small"
}
}
}
});
export function AppThemeProvider({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
type ConfirmDialogProps = {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
};
export function ConfirmDialog({
open,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
onConfirm,
onCancel
}: ConfirmDialogProps) {
return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs">
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Typography color="text.secondary">{description}</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2.5 }}>
<Button variant="outlined" color="inherit" onClick={onCancel}>
{cancelLabel}
</Button>
<Button variant="contained" color="error" onClick={onConfirm}>
{confirmLabel}
</Button>
</DialogActions>
</Dialog>
);
}
+45 -56
View File
@@ -1,5 +1,6 @@
"use client";
import { Alert, Box, Card, CardContent, Divider, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { EmptyState } from "@/components/EmptyState";
@@ -12,7 +13,6 @@ export function DashboardClient() {
const [data, setData] = useState<DashboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
async function loadDashboard() {
setLoading(true);
@@ -28,16 +28,6 @@ export function DashboardClient() {
}
}
async function seedSampleData() {
try {
const response = await fetchJson<{ seeded: boolean; reason?: string }>("/api/seed", { method: "POST" });
setMessage(response.seeded ? "Sample team loaded." : response.reason ?? "Seed skipped.");
await loadDashboard();
} catch (seedError) {
setError(seedError instanceof Error ? seedError.message : "Failed to seed data.");
}
}
useEffect(() => {
void loadDashboard();
}, []);
@@ -46,76 +36,75 @@ export function DashboardClient() {
<AppShell
title="Game Day Snapshot"
subtitle="Check roster health, pitcher rest, and lineup readiness before first pitch."
actions={
<button className="button button-secondary" onClick={() => void seedSampleData()}>
Load Sample Team
</button>
}
>
{message ? <div className="banner banner-success">{message}</div> : null}
{error ? <div className="banner banner-error">{error}</div> : null}
{error ? <Alert severity="error">{error}</Alert> : null}
{loading ? (
<SectionCard title="Loading" description="Pulling current roster and pitching data." />
) : data && data.rosterCount > 0 ? (
<>
<section className="stats-grid">
<article className="stat-card">
<span>Rostered Players</span>
<strong>{data.rosterCount}</strong>
</article>
<article className="stat-card">
<span>Active Today</span>
<strong>{data.activePlayers}</strong>
</article>
<article className="stat-card stat-card-success">
<span>Pitchers Available</span>
<strong>{data.availablePitchers}</strong>
</article>
<article className="stat-card stat-card-warning">
<span>Need Rest</span>
<strong>{data.needsRest}</strong>
</article>
</section>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", lg: "repeat(4, 1fr)" }, gap: 2 }}>
{[
{ label: "Rostered Players", value: data.rosterCount },
{ label: "Active Today", value: data.activePlayers },
{ label: "Pitchers Available", value: data.availablePitchers, color: "success.main" },
{ label: "Need Rest", value: data.needsRest, color: "warning.main" }
].map((item) => (
<Card key={item.label} elevation={0} sx={{ border: "1px solid rgba(15, 38, 47, 0.08)" }}>
<CardContent>
<Typography color="text.secondary" variant="body2">
{item.label}
</Typography>
<Typography variant="h2" sx={{ mt: 1, color: item.color ?? "text.primary", fontSize: "2.35rem" }}>
{item.value}
</Typography>
</CardContent>
</Card>
))}
</Box>
<SectionCard
title="Pitcher Availability"
description="Availability is based on the latest appearance and your configured rest rules."
>
<div className="list-stack">
<Stack divider={<Divider flexItem />} spacing={0}>
{data.recentPitching.map((entry) => (
<article key={entry.playerId} className="list-row">
<div>
<h3>{entry.playerName}</h3>
<p>
<Box
key={entry.playerId}
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
justifyContent: "space-between",
gap: 1.5,
py: 2
}}
>
<Box>
<Typography variant="h6">{entry.playerName}</Typography>
<Typography color="text.secondary">
{entry.lastPitchedDate
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
: "No pitching history recorded yet."}
</p>
</div>
<div className="list-meta">
</Typography>
</Box>
<Stack spacing={0.75} sx={{ alignItems: { xs: "flex-start", md: "flex-end" } }}>
<StatusBadge status={entry.status} />
<small>
<Typography variant="body2" color="text.secondary">
{entry.daysSinceLastAppearance === null
? "No history"
: `${entry.daysSinceLastAppearance} day(s) since last outing`}
</small>
</div>
</article>
</Typography>
</Stack>
</Box>
))}
</div>
</Stack>
</SectionCard>
</>
) : (
<SectionCard title="No Team Yet" description="Start with sample data or add players on the roster screen.">
<SectionCard title="No Team Yet" description="Add players on the roster screen to start tracking availability.">
<EmptyState
title="Roster is empty"
description="Load the sample team to explore the app, then adjust players and pitching records as needed."
action={
<button className="button" onClick={() => void seedSampleData()}>
Load Sample Team
</button>
}
description="Add your team on the roster screen, then return here to review availability and game-day readiness."
/>
</SectionCard>
)}
+15 -4
View File
@@ -1,3 +1,5 @@
import { Box, Typography } from "@mui/material";
export function EmptyState({
title,
description,
@@ -8,10 +10,19 @@ export function EmptyState({
action?: React.ReactNode;
}) {
return (
<div className="empty-state">
<h3>{title}</h3>
<p>{description}</p>
<Box
sx={{
p: { xs: 3, md: 4 },
borderRadius: 4,
border: "1px dashed rgba(15, 38, 47, 0.16)",
background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(248,243,235,0.9) 100%)",
textAlign: "center"
}}
>
<Typography variant="h5">{title}</Typography>
<Typography sx={{ mt: 1, color: "text.secondary" }}>{description}</Typography>
{action}
</div>
</Box>
);
}
+114 -74
View File
@@ -1,5 +1,6 @@
"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";
@@ -94,73 +95,102 @@ export function LineupClient() {
title="Generate Lineup"
subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed."
>
{message ? <div className="banner banner-success">{message}</div> : null}
{error ? <div className="banner banner-error">{error}</div> : null}
{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.">
<div className="form-grid">
<label>
<span>Game Date</span>
<input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
</label>
<label>
<span>Starting Pitcher</span>
<select value={startingPitcherId} onChange={(event) => setStartingPitcherId(event.target.value)}>
{selectablePitchers.map((player) => (
<option key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</option>
))}
</select>
</label>
<label className="checkbox-row">
<input type="checkbox" checked={showAllPitchers} onChange={(event) => setShowAllPitchers(event.target.checked)} />
<span>Show pitchers who need rest</span>
</label>
<div className="full-width action-row">
<button className="button" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
<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 className="button button-secondary" disabled={!generated} onClick={() => void save()}>
</Button>
<Button variant="outlined" color="inherit" disabled={!generated} onClick={() => void save()}>
Save Lineup
</button>
</div>
</div>
</Button>
</Stack>
</Box>
<div className="picker-grid">
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(2, 1fr)" }, gap: 1.5, mt: 3 }}>
{players.map((player) => (
<label key={player._id} className="picker-card">
<input
type="checkbox"
checked={selectedPlayerIds.includes(player._id)}
onChange={(event) =>
setSelectedPlayerIds((current) =>
event.target.checked ? [...current, player._id] : current.filter((id) => id !== player._id)
)
}
/>
<div>
<strong>{formatPlayerName(player.firstName, player.lastName)}</strong>
<p>
#{player.jerseyNumber} {player.primaryPosition}
</p>
</div>
<StatusBadge status={player.pitcherAvailability.status} />
</label>
<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>
))}
</div>
</Box>
</SectionCard>
{generated ? (
<>
<SectionCard title="Batting Order" description="Adjust any slot manually before saving.">
<div className="list-stack">
<Stack divider={<Divider flexItem />} spacing={0}>
{generated.battingOrder.map((slot, index) => (
<div key={`${slot.playerId}-${index}`} className="list-row">
<div>
<h3>Spot {index + 1}</h3>
</div>
<select
<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);
@@ -180,26 +210,34 @@ export function LineupClient() {
}}
>
{selectedPlayers.map((player) => (
<option key={player._id} value={player._id}>
<MenuItem key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</option>
</MenuItem>
))}
</select>
</div>
</TextField>
</Box>
))}
</div>
</Stack>
</SectionCard>
<SectionCard title="Defensive Alignment" description="Assignments follow position preferences, but every field is editable.">
<div className="list-stack">
<Stack divider={<Divider flexItem />} spacing={0}>
{FIELD_POSITIONS.map((position) => {
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
return (
<div key={position} className="list-row">
<div>
<h3>{position}</h3>
</div>
<select
<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);
@@ -216,23 +254,25 @@ export function LineupClient() {
});
}}
>
<option value="">Unassigned</option>
<MenuItem value="">Unassigned</MenuItem>
{selectedPlayers.map((player) => (
<option key={player._id} value={player._id}>
<MenuItem key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</option>
</MenuItem>
))}
</select>
</div>
</TextField>
</Box>
);
})}
</div>
</Stack>
{generated.notes.length > 0 ? (
<div className="note-block">
<Box sx={{ mt: 2, pl: 2, borderLeft: "4px solid", borderColor: "warning.main" }}>
{generated.notes.map((note) => (
<p key={note}>{note}</p>
<Typography key={note} color="text.secondary">
{note}
</Typography>
))}
</div>
</Box>
) : null}
</SectionCard>
</>
+82 -77
View File
@@ -1,5 +1,6 @@
"use client";
import { Box, Button, Checkbox, FormControlLabel, MenuItem, Stack, TextField } from "@mui/material";
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
import type { PlayerInput, PlayerRecord } from "@/lib/types";
@@ -45,84 +46,88 @@ export function PlayerForm({
submitLabel: string;
}) {
return (
<div className="form-grid">
<label>
<span>First Name</span>
<input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
</label>
<label>
<span>Last Name</span>
<input value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
</label>
<label>
<span>Jersey #</span>
<input
type="number"
value={value.jerseyNumber}
onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
<TextField label="First Name" value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
<TextField label="Last Name" value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
<TextField
label="Jersey #"
type="number"
value={value.jerseyNumber}
onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })}
/>
<TextField
select
label="Bats / Throws"
value={value.batsThrows}
onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}
>
{HANDEDNESS_OPTIONS.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</TextField>
<TextField
select
label="Primary Position"
value={value.primaryPosition}
onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })}
>
{POSITIONS.map((position) => (
<MenuItem key={position} value={position}>
{position}
</MenuItem>
))}
</TextField>
<TextField
select
label="Secondary Position"
value={value.secondaryPosition ?? ""}
onChange={(event) =>
onChange({ ...value, secondaryPosition: event.target.value ? (event.target.value as PlayerInput["secondaryPosition"]) : null })
}
>
<MenuItem value="">None</MenuItem>
{POSITIONS.map((position) => (
<MenuItem key={position} value={position}>
{position}
</MenuItem>
))}
</TextField>
<TextField
select
label="Tertiary Position"
value={value.tertiaryPosition ?? ""}
onChange={(event) =>
onChange({ ...value, tertiaryPosition: event.target.value ? (event.target.value as PlayerInput["tertiaryPosition"]) : null })
}
>
<MenuItem value="">None</MenuItem>
{POSITIONS.map((position) => (
<MenuItem key={position} value={position}>
{position}
</MenuItem>
))}
</TextField>
<Stack sx={{ justifyContent: "center" }}>
<FormControlLabel
control={<Checkbox checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} />}
label="Active for selection"
/>
</label>
<label>
<span>Bats / Throws</span>
<select value={value.batsThrows} onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}>
{HANDEDNESS_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label>
<span>Primary Position</span>
<select value={value.primaryPosition} onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })}>
{POSITIONS.map((position) => (
<option key={position} value={position}>
{position}
</option>
))}
</select>
</label>
<label>
<span>Secondary Position</span>
<select
value={value.secondaryPosition ?? ""}
onChange={(event) => onChange({ ...value, secondaryPosition: event.target.value ? (event.target.value as PlayerInput["secondaryPosition"]) : null })}
>
<option value="">None</option>
{POSITIONS.map((position) => (
<option key={position} value={position}>
{position}
</option>
))}
</select>
</label>
<label>
<span>Tertiary Position</span>
<select
value={value.tertiaryPosition ?? ""}
onChange={(event) => onChange({ ...value, tertiaryPosition: event.target.value ? (event.target.value as PlayerInput["tertiaryPosition"]) : null })}
>
<option value="">None</option>
{POSITIONS.map((position) => (
<option key={position} value={position}>
{position}
</option>
))}
</select>
</label>
<label className="checkbox-row">
<input type="checkbox" checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} />
<span>Active for selection</span>
</label>
<label className="full-width">
<span>Notes</span>
<textarea rows={4} value={value.notes ?? ""} onChange={(event) => onChange({ ...value, notes: event.target.value })} />
</label>
<div className="full-width">
<button className="button" onClick={onSubmit}>
</Stack>
<TextField
label="Notes"
multiline
rows={4}
value={value.notes ?? ""}
onChange={(event) => onChange({ ...value, notes: event.target.value })}
sx={{ gridColumn: "1 / -1" }}
/>
<Box sx={{ gridColumn: "1 / -1" }}>
<Button variant="contained" onClick={onSubmit}>
{submitLabel}
</button>
</div>
</div>
</Button>
</Box>
</Box>
);
}
+128 -95
View File
@@ -1,7 +1,9 @@
"use client";
import { Alert, Box, Button, Card, CardContent, Divider, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { EmptyState } from "@/components/EmptyState";
import { PlayerForm, emptyPlayerForm, toPlayerForm } from "@/components/PlayerForm";
import { SectionCard } from "@/components/SectionCard";
@@ -16,6 +18,7 @@ export function RosterClient() {
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null);
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
const [appearanceForm, setAppearanceForm] = useState({
playerId: "",
@@ -72,13 +75,10 @@ export function RosterClient() {
}
async function deletePlayer(player: PlayerRecord) {
if (!window.confirm(`Remove ${formatPlayerName(player.firstName, player.lastName)} from the roster?`)) {
return;
}
try {
await fetchJson(`/api/players/${player._id}`, { method: "DELETE" });
setMessage("Player removed.");
setPlayerPendingDelete(null);
await loadPlayers();
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete player.");
@@ -103,8 +103,21 @@ export function RosterClient() {
title="Roster Management"
subtitle="Add players, update availability, and keep pitching history current from one screen."
>
{message ? <div className="banner banner-success">{message}</div> : null}
{error ? <div className="banner banner-error">{error}</div> : null}
<ConfirmDialog
open={playerPendingDelete !== null}
title="Remove Player?"
description={
playerPendingDelete
? `Remove ${formatPlayerName(playerPendingDelete.firstName, playerPendingDelete.lastName)} from the roster? This also removes their pitching history and saved lineup references.`
: ""
}
confirmLabel="Remove Player"
onCancel={() => setPlayerPendingDelete(null)}
onConfirm={() => (playerPendingDelete ? void deletePlayer(playerPendingDelete) : undefined)}
/>
{message ? <Alert severity="success">{message}</Alert> : null}
{error ? <Alert severity="error">{error}</Alert> : null}
<SectionCard
title={editingPlayerId ? "Edit Player" : "Add Player"}
@@ -114,104 +127,124 @@ export function RosterClient() {
</SectionCard>
<SectionCard title="Pitch Count Log" description="Record the latest outing to keep rest recommendations accurate.">
<div className="form-grid">
<label>
<span>Player</span>
<select
value={appearanceForm.playerId}
onChange={(event) => setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))}
>
{players.map((player) => (
<option key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</option>
))}
</select>
</label>
<label>
<span>Game Date</span>
<input
type="date"
value={appearanceForm.gameDate}
onChange={(event) => setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))}
/>
</label>
<label>
<span>Pitches Thrown</span>
<input
type="number"
value={appearanceForm.pitchCount}
onChange={(event) => setAppearanceForm((current) => ({ ...current, pitchCount: Number(event.target.value) }))}
/>
</label>
<label className="full-width">
<span>Notes</span>
<textarea
rows={3}
value={appearanceForm.notes}
onChange={(event) => setAppearanceForm((current) => ({ ...current, notes: event.target.value }))}
/>
</label>
<div className="full-width">
<button className="button" onClick={() => void logAppearance()} disabled={players.length === 0}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 2 }}>
<TextField
select
label="Player"
value={appearanceForm.playerId}
onChange={(event) => setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))}
>
{players.map((player) => (
<MenuItem key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</MenuItem>
))}
</TextField>
<TextField
label="Game Date"
type="date"
value={appearanceForm.gameDate}
onChange={(event) => setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Pitches Thrown"
type="number"
value={appearanceForm.pitchCount}
onChange={(event) => setAppearanceForm((current) => ({ ...current, pitchCount: Number(event.target.value) }))}
/>
<TextField
label="Notes"
multiline
rows={3}
value={appearanceForm.notes}
onChange={(event) => setAppearanceForm((current) => ({ ...current, notes: event.target.value }))}
sx={{ gridColumn: "1 / -1" }}
/>
<Box sx={{ gridColumn: "1 / -1" }}>
<Button variant="contained" onClick={() => void logAppearance()} disabled={players.length === 0}>
Save Pitching Appearance
</button>
</div>
</div>
</Button>
</Box>
</Box>
</SectionCard>
<SectionCard title="Current Roster" description="Tap edit for fast changes and check pitcher status at a glance.">
{loading ? (
<p className="muted-copy">Loading roster...</p>
<Typography color="text.secondary">Loading roster...</Typography>
) : players.length > 0 ? (
<div className="card-grid">
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "repeat(2, 1fr)" }, gap: 2 }}>
{players.map((player) => (
<article key={player._id} className="player-card">
<div className="player-card-head">
<div>
<h3>
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
</h3>
<p>
{player.primaryPosition}
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
</p>
</div>
<StatusBadge status={player.pitcherAvailability.status} />
</div>
<dl className="player-meta">
<div>
<dt>Bats/Throws</dt>
<dd>{player.batsThrows}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{player.isActive ? "Active" : "Inactive"}</dd>
</div>
<div>
<dt>Pitching</dt>
<dd>
{player.pitcherAvailability.lastPitchedDate
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
: "No history"}
</dd>
</div>
</dl>
{player.notes ? <p className="player-notes">{player.notes}</p> : null}
<div className="action-row">
<button className="button button-secondary" onClick={() => setEditingPlayerId(player._id)}>
Edit
</button>
<button className="button button-danger" onClick={() => void deletePlayer(player)}>
Delete
</button>
</div>
</article>
<Card key={player._id} elevation={0} sx={{ border: "1px solid rgba(15, 38, 47, 0.08)" }}>
<CardContent sx={{ display: "grid", gap: 2 }}>
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
justifyContent: "space-between",
gap: 1.5
}}
>
<Box>
<Typography variant="h6">
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
</Typography>
<Typography color="text.secondary">
{player.primaryPosition}
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
</Typography>
</Box>
<StatusBadge status={player.pitcherAvailability.status} />
</Box>
<Divider />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" }, gap: 1.5 }}>
<Box>
<Typography variant="caption" color="text.secondary">
Bats/Throws
</Typography>
<Typography>{player.batsThrows}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Status
</Typography>
<Typography>{player.isActive ? "Active" : "Inactive"}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Pitching
</Typography>
<Typography>
{player.pitcherAvailability.lastPitchedDate
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
: "No history"}
</Typography>
</Box>
</Box>
{player.notes ? (
<Box sx={{ p: 1.5, borderRadius: 3, bgcolor: "rgba(15, 92, 77, 0.06)" }}>
<Typography color="text.secondary">{player.notes}</Typography>
</Box>
) : null}
<Stack direction="row" spacing={1.25}>
<Button variant="outlined" color="inherit" onClick={() => setEditingPlayerId(player._id)}>
Edit
</Button>
<Button variant="contained" color="error" onClick={() => setPlayerPendingDelete(player)}>
Delete
</Button>
</Stack>
</CardContent>
</Card>
))}
</div>
</Box>
) : (
<EmptyState title="No players yet" description="Add your first player above or load sample data from the dashboard." />
<EmptyState title="No players yet" description="Add your first player above to start building the roster." />
)}
</SectionCard>
</AppShell>
+22 -9
View File
@@ -1,3 +1,5 @@
import { Card, CardContent, Stack, Typography } from "@mui/material";
export function SectionCard({
title,
description,
@@ -8,14 +10,25 @@ export function SectionCard({
children?: React.ReactNode;
}) {
return (
<section className="section-card">
<div className="section-heading">
<div>
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
</div>
</div>
{children}
</section>
<Card
elevation={0}
sx={{
border: "1px solid rgba(15, 38, 47, 0.08)",
backgroundColor: "rgba(255,255,255,0.8)",
backdropFilter: "blur(10px)"
}}
>
<CardContent sx={{ p: { xs: 2, md: 3 }, "&:last-child": { pb: { xs: 2, md: 3 } } }}>
<Stack spacing={description ? 2.5 : 2}>
<Stack spacing={0.75}>
<Typography variant="h2" sx={{ fontSize: { xs: "1.65rem", md: "2rem" } }}>
{title}
</Typography>
{description ? <Typography color="text.secondary">{description}</Typography> : null}
</Stack>
{children}
</Stack>
</CardContent>
</Card>
);
}
+33 -32
View File
@@ -1,5 +1,6 @@
"use client";
import { Alert, Box, Button, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField } from "@mui/material";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { SectionCard } from "@/components/SectionCard";
@@ -50,24 +51,24 @@ export function SettingsClient() {
title="Pitching Rest Rules"
subtitle="Tune the rest windows your league uses so eligibility stays accurate and easy to trust."
>
{message ? <div className="banner banner-success">{message}</div> : null}
{error ? <div className="banner banner-error">{error}</div> : null}
{message ? <Alert severity="success">{message}</Alert> : null}
{error ? <Alert severity="error">{error}</Alert> : null}
<SectionCard title="Rule Table" description="Each row maps a pitch-count range to required rest days.">
<div className="table-wrap">
<table className="rule-table">
<thead>
<tr>
<th>Min Pitches</th>
<th>Max Pitches</th>
<th>Rest Days</th>
</tr>
</thead>
<tbody>
<Box sx={{ overflowX: "auto" }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Min Pitches</TableCell>
<TableCell>Max Pitches</TableCell>
<TableCell>Rest Days</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rules.map((rule, index) => (
<tr key={`${rule.minPitches}-${index}`}>
<td>
<input
<TableRow key={`${rule.minPitches}-${index}`}>
<TableCell>
<TextField
type="number"
value={rule.minPitches}
onChange={(event) =>
@@ -78,9 +79,9 @@ export function SettingsClient() {
)
}
/>
</td>
<td>
<input
</TableCell>
<TableCell>
<TextField
type="number"
value={rule.maxPitches ?? ""}
placeholder="No max"
@@ -94,9 +95,9 @@ export function SettingsClient() {
)
}
/>
</td>
<td>
<input
</TableCell>
<TableCell>
<TextField
type="number"
value={rule.requiredRestDays}
onChange={(event) =>
@@ -107,21 +108,21 @@ export function SettingsClient() {
)
}
/>
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</div>
</TableBody>
</Table>
</Box>
<div className="action-row">
<button className="button button-secondary" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.25}>
<Button variant="outlined" color="inherit" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
Reset to Example Rules
</button>
<button className="button" onClick={() => void saveRules()}>
</Button>
<Button variant="contained" onClick={() => void saveRules()}>
Save Rules
</button>
</div>
</Button>
</Stack>
</SectionCard>
</AppShell>
);
+5 -2
View File
@@ -1,6 +1,9 @@
import { Chip } from "@mui/material";
import type { PitcherAvailability } from "@/lib/types";
import { cn, statusLabel } from "@/lib/utils";
import { statusLabel } from "@/lib/utils";
export function StatusBadge({ status }: { status: PitcherAvailability }) {
return <span className={cn("badge", `badge-${status}`)}>{statusLabel(status)}</span>;
const color = status === "available" ? "success" : status === "needs-rest" ? "error" : "default";
return <Chip label={statusLabel(status)} color={color} variant={status === "unknown" ? "outlined" : "filled"} size="small" />;
}