Updating
This commit is contained in:
Generated
+1392
-792
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,18 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@emotion/styled": "^11.14.1",
|
"autoprefixer": "^10.5.0",
|
||||||
"@mui/icons-material": "^9.0.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
"@mui/material": "^9.0.0",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
"mongoose": "^8.14.3",
|
"mongoose": "^8.14.3",
|
||||||
"next": "^15.3.2",
|
"next": "^15.3.2",
|
||||||
|
"postcss": "^8.5.14",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"zod": "^3.24.4"
|
"zod": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,27 +1,43 @@
|
|||||||
:root {
|
@tailwind base;
|
||||||
--font-sans: "Avenir Next", "Segoe UI", "Trebuchet MS", sans-serif;
|
@tailwind components;
|
||||||
--font-display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at top left, rgba(15, 92, 77, 0.16), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(207, 111, 46, 0.18), transparent 24%),
|
||||||
|
linear-gradient(180deg, #fcfaf5 0%, #f7f1e5 50%, #f2e7d4 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply text-inherit no-underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
@layer components {
|
||||||
box-sizing: border-box;
|
.app-surface {
|
||||||
}
|
@apply rounded-3xl border border-white/70 bg-card/85 shadow-panel backdrop-blur;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
.field-label {
|
||||||
font-size: 16px;
|
@apply mb-2 block text-sm font-medium text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.field-select {
|
||||||
margin: 0;
|
@apply flex h-11 w-full rounded-2xl border border-input bg-white/90 px-4 py-2 text-sm text-foreground shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
|
||||||
min-height: 100vh;
|
}
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(15, 92, 77, 0.18), transparent 28%),
|
|
||||||
radial-gradient(circle at top right, rgba(207, 111, 46, 0.18), transparent 24%),
|
|
||||||
linear-gradient(180deg, #fbf7ef 0%, #f4efe3 44%, #efe5d4 100%);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { AppThemeProvider } from "@/components/AppThemeProvider";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -10,9 +9,7 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body>{children}</body>
|
||||||
<AppThemeProvider>{children}</AppThemeProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import SportsBaseballRoundedIcon from "@mui/icons-material/SportsBaseballRounded";
|
import { BarChart3, ClipboardList, ShieldCheck, Trophy, Users } from "lucide-react";
|
||||||
import { Box, Chip, Container, Paper, Tab, Tabs, Typography } from "@mui/material";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 },
|
||||||
{ href: "/roster", label: "Roster" },
|
{ href: "/roster", label: "Roster", icon: Users },
|
||||||
{ href: "/lineup", label: "Generate Lineup" },
|
{ href: "/lineup", label: "Generate Lineup", icon: ClipboardList },
|
||||||
{ href: "/settings", label: "Pitching Rules" }
|
{ href: "/settings", label: "Pitching Rules", icon: ShieldCheck }
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppShell({
|
export function AppShell({
|
||||||
@@ -24,95 +24,47 @@ export function AppShell({
|
|||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const currentTab = navItems.find((item) => pathname === item.href)?.href ?? false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: { xs: 2, md: 4 } }}>
|
<div className="container py-4 md:py-8">
|
||||||
<Paper
|
<header className="relative overflow-hidden rounded-[2rem] bg-[linear-gradient(135deg,rgba(8,35,47,0.98)_0%,rgba(14,87,72,0.96)_52%,rgba(203,105,48,0.92)_100%)] p-6 text-white shadow-glow md:p-10">
|
||||||
elevation={0}
|
<div className="pointer-events-none absolute inset-0 bg-hero-grid bg-[size:38px_38px] opacity-20" />
|
||||||
sx={{
|
<div className="relative flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||||
p: { xs: 3, md: 4 },
|
<div className="max-w-3xl">
|
||||||
borderRadius: 7,
|
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-medium text-white/90 backdrop-blur">
|
||||||
color: "common.white",
|
<Trophy className="h-4 w-4" />
|
||||||
background:
|
Little League Coach Console
|
||||||
"linear-gradient(135deg, rgba(9,45,64,0.98) 0%, rgba(15,92,77,0.96) 50%, rgba(207,111,46,0.92) 100%)",
|
</div>
|
||||||
boxShadow: "0 24px 64px rgba(16, 41, 52, 0.24)"
|
<h1 className="font-display text-4xl font-semibold leading-none tracking-tight md:text-6xl">{title}</h1>
|
||||||
}}
|
<p className="mt-4 max-w-2xl text-base text-white/80 md:text-lg">{subtitle}</p>
|
||||||
>
|
</div>
|
||||||
<Box
|
{actions ? <div className="relative flex items-start">{actions}</div> : null}
|
||||||
sx={{
|
</div>
|
||||||
display: "flex",
|
</header>
|
||||||
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>
|
|
||||||
|
|
||||||
<Paper
|
<nav className="app-surface mt-4 flex flex-wrap gap-2 p-2">
|
||||||
elevation={0}
|
{navItems.map((item) => {
|
||||||
sx={{
|
const Icon = item.icon;
|
||||||
mt: 2,
|
const active = pathname === item.href;
|
||||||
p: 1,
|
return (
|
||||||
borderRadius: 5,
|
<Link
|
||||||
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}
|
key={item.href}
|
||||||
component={Link}
|
|
||||||
href={item.href}
|
href={item.href}
|
||||||
value={item.href}
|
className={cn(
|
||||||
label={item.label}
|
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition",
|
||||||
sx={{
|
active
|
||||||
minHeight: 0,
|
? "bg-white text-foreground shadow-panel"
|
||||||
borderRadius: 999,
|
: "text-muted-foreground hover:bg-white/80 hover:text-foreground"
|
||||||
color: "text.secondary",
|
)}
|
||||||
"&.Mui-selected": {
|
>
|
||||||
color: "text.primary",
|
<Icon className="h-4 w-4" />
|
||||||
bgcolor: "background.paper",
|
{item.label}
|
||||||
boxShadow: "0 10px 24px rgba(16, 41, 52, 0.08)"
|
</Link>
|
||||||
}
|
);
|
||||||
}}
|
})}
|
||||||
/>
|
</nav>
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Box component="main" sx={{ display: "grid", gap: 2.25, mt: 2.5 }}>
|
<main className="mt-5 grid gap-5">{children}</main>
|
||||||
{children}
|
</div>
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
type ConfirmDialogProps = {
|
type ConfirmDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,19 +24,31 @@ export function ConfirmDialog({
|
|||||||
onCancel
|
onCancel
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs">
|
<Dialog.Root open={open} onOpenChange={(nextOpen) => (!nextOpen ? onCancel() : undefined)}>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<Dialog.Portal>
|
||||||
<DialogContent>
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-slate-950/50 backdrop-blur-sm" />
|
||||||
<Typography color="text.secondary">{description}</Typography>
|
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-[1.75rem] border border-white/60 bg-card p-6 shadow-glow focus:outline-none">
|
||||||
</DialogContent>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<DialogActions sx={{ px: 3, pb: 2.5 }}>
|
<div>
|
||||||
<Button variant="outlined" color="inherit" onClick={onCancel}>
|
<Dialog.Title className="font-display text-2xl font-semibold">{title}</Dialog.Title>
|
||||||
{cancelLabel}
|
<Dialog.Description className="mt-2 text-sm text-muted-foreground">{description}</Dialog.Description>
|
||||||
</Button>
|
</div>
|
||||||
<Button variant="contained" color="error" onClick={onConfirm}>
|
<Dialog.Close asChild>
|
||||||
{confirmLabel}
|
<button className="rounded-full p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close">
|
||||||
</Button>
|
<X className="h-4 w-4" />
|
||||||
</DialogActions>
|
</button>
|
||||||
</Dialog>
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<Button variant="secondary" onClick={onCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Alert, Box, Card, CardContent, Divider, Stack, Typography } from "@mui/material";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { SectionCard } from "@/components/SectionCard";
|
import { SectionCard } from "@/components/SectionCard";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { fetchJson } from "@/lib/client";
|
import { fetchJson } from "@/lib/client";
|
||||||
import type { DashboardResponse } from "@/lib/types";
|
import type { DashboardResponse } from "@/lib/types";
|
||||||
|
|
||||||
@@ -37,67 +38,59 @@ export function DashboardClient() {
|
|||||||
title="Game Day Snapshot"
|
title="Game Day Snapshot"
|
||||||
subtitle="Check roster health, pitcher rest, and lineup readiness before first pitch."
|
subtitle="Check roster health, pitcher rest, and lineup readiness before first pitch."
|
||||||
>
|
>
|
||||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SectionCard title="Loading" description="Pulling current roster and pitching data." />
|
<SectionCard title="Loading" description="Pulling current roster and pitching data." />
|
||||||
) : data && data.rosterCount > 0 ? (
|
) : data && data.rosterCount > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", lg: "repeat(4, 1fr)" }, gap: 2 }}>
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{[
|
{[
|
||||||
{ label: "Rostered Players", value: data.rosterCount },
|
{ label: "Rostered Players", value: data.rosterCount },
|
||||||
{ label: "Active Today", value: data.activePlayers },
|
{ label: "Active Today", value: data.activePlayers },
|
||||||
{ label: "Pitchers Available", value: data.availablePitchers, color: "success.main" },
|
{ label: "Pitchers Available", value: data.availablePitchers, color: "text-emerald-700" },
|
||||||
{ label: "Need Rest", value: data.needsRest, color: "warning.main" }
|
{ label: "Need Rest", value: data.needsRest, color: "text-amber-700" }
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<Card key={item.label} elevation={0} sx={{ border: "1px solid rgba(15, 38, 47, 0.08)" }}>
|
<Card key={item.label} className="overflow-hidden">
|
||||||
<CardContent>
|
<CardContent className="pt-6">
|
||||||
<Typography color="text.secondary" variant="body2">
|
<p className="text-sm text-muted-foreground">{item.label}</p>
|
||||||
{item.label}
|
<p className={`mt-2 font-display text-5xl leading-none ${item.color ?? "text-foreground"}`}>
|
||||||
</Typography>
|
|
||||||
<Typography variant="h2" sx={{ mt: 1, color: item.color ?? "text.primary", fontSize: "2.35rem" }}>
|
|
||||||
{item.value}
|
{item.value}
|
||||||
</Typography>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Pitcher Availability"
|
title="Pitcher Availability"
|
||||||
description="Availability is based on the latest appearance and your configured rest rules."
|
description="Availability is based on the latest appearance and your configured rest rules."
|
||||||
>
|
>
|
||||||
<Stack divider={<Divider flexItem />} spacing={0}>
|
<div className="divide-y divide-border">
|
||||||
{data.recentPitching.map((entry) => (
|
{data.recentPitching.map((entry) => (
|
||||||
<Box
|
<div
|
||||||
key={entry.playerId}
|
key={entry.playerId}
|
||||||
sx={{
|
className="flex flex-col gap-3 py-4 md:flex-row md:items-start md:justify-between"
|
||||||
display: "flex",
|
|
||||||
flexDirection: { xs: "column", md: "row" },
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 1.5,
|
|
||||||
py: 2
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="h6">{entry.playerName}</Typography>
|
<h3 className="text-lg font-semibold">{entry.playerName}</h3>
|
||||||
<Typography color="text.secondary">
|
<p className="text-sm text-muted-foreground">
|
||||||
{entry.lastPitchedDate
|
{entry.lastPitchedDate
|
||||||
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
|
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
|
||||||
: "No pitching history recorded yet."}
|
: "No pitching history recorded yet."}
|
||||||
</Typography>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
<Stack spacing={0.75} sx={{ alignItems: { xs: "flex-start", md: "flex-end" } }}>
|
<div className="flex flex-col items-start gap-2 md:items-end">
|
||||||
<StatusBadge status={entry.status} />
|
<StatusBadge status={entry.status} />
|
||||||
<Typography variant="body2" color="text.secondary">
|
<p className="text-sm text-muted-foreground">
|
||||||
{entry.daysSinceLastAppearance === null
|
{entry.daysSinceLastAppearance === null
|
||||||
? "No history"
|
? "No history"
|
||||||
: `${entry.daysSinceLastAppearance} day(s) since last outing`}
|
: `${entry.daysSinceLastAppearance} day(s) since last outing`}
|
||||||
</Typography>
|
</p>
|
||||||
</Stack>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Box, Typography } from "@mui/material";
|
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -10,19 +8,10 @@ export function EmptyState({
|
|||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<div className="rounded-[1.75rem] border border-dashed border-border bg-muted/50 px-6 py-10 text-center">
|
||||||
sx={{
|
<h3 className="font-display text-2xl font-semibold">{title}</h3>
|
||||||
p: { xs: 3, md: 4 },
|
<p className="mx-auto mt-2 max-w-xl text-sm text-muted-foreground">{description}</p>
|
||||||
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}
|
{action}
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
"use client";
|
"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 { useEffect, useMemo, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { SectionCard } from "@/components/SectionCard";
|
import { SectionCard } from "@/components/SectionCard";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { FIELD_POSITIONS } from "@/lib/constants";
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { fetchJson } from "@/lib/client";
|
import { fetchJson } from "@/lib/client";
|
||||||
|
import { FIELD_POSITIONS } from "@/lib/constants";
|
||||||
import { formatPlayerName } from "@/lib/utils";
|
import { formatPlayerName } from "@/lib/utils";
|
||||||
import type { GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
|
import type { GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
|
||||||
|
|
||||||
@@ -40,9 +43,10 @@ export function LineupClient() {
|
|||||||
|
|
||||||
const selectablePitchers = useMemo(
|
const selectablePitchers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
players.filter((player) =>
|
players.filter(
|
||||||
selectedPlayerIds.includes(player._id) &&
|
(player) =>
|
||||||
(showAllPitchers || player.pitcherAvailability.status !== "needs-rest")
|
selectedPlayerIds.includes(player._id) &&
|
||||||
|
(showAllPitchers || player.pitcherAvailability.status !== "needs-rest")
|
||||||
),
|
),
|
||||||
[players, selectedPlayerIds, showAllPitchers]
|
[players, selectedPlayerIds, showAllPitchers]
|
||||||
);
|
);
|
||||||
@@ -95,102 +99,101 @@ export function LineupClient() {
|
|||||||
title="Generate Lineup"
|
title="Generate Lineup"
|
||||||
subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed."
|
subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed."
|
||||||
>
|
>
|
||||||
{message ? <Alert severity="success">{message}</Alert> : null}
|
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||||
|
|
||||||
<SectionCard title="Game Setup" description="Start with the players who are available today.">
|
<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 }}>
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
<TextField
|
<label>
|
||||||
label="Game Date"
|
<span className="field-label">Game Date</span>
|
||||||
type="date"
|
<Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
|
||||||
value={gameDate}
|
</label>
|
||||||
onChange={(event) => setGameDate(event.target.value)}
|
<label>
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
<span className="field-label">Starting Pitcher</span>
|
||||||
/>
|
<select
|
||||||
<TextField select label="Starting Pitcher" value={startingPitcherId} onChange={(event) => setStartingPitcherId(event.target.value)}>
|
className="field-select"
|
||||||
{selectablePitchers.map((player) => (
|
value={startingPitcherId}
|
||||||
<MenuItem key={player._id} value={player._id}>
|
onChange={(event) => setStartingPitcherId(event.target.value)}
|
||||||
{formatPlayerName(player.firstName, player.lastName)}
|
>
|
||||||
</MenuItem>
|
{selectablePitchers.map((player) => (
|
||||||
))}
|
<option key={player._id} value={player._id}>
|
||||||
</TextField>
|
{formatPlayerName(player.firstName, player.lastName)}
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
</option>
|
||||||
<FormControlLabel
|
))}
|
||||||
control={<Checkbox checked={showAllPitchers} onChange={(event) => setShowAllPitchers(event.target.checked)} />}
|
</select>
|
||||||
label="Show pitchers who need rest"
|
</label>
|
||||||
|
<label className="md:col-span-2 flex items-center gap-3 rounded-2xl border border-border bg-white/70 px-4 py-3 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showAllPitchers}
|
||||||
|
onChange={(event) => setShowAllPitchers(event.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</Box>
|
Show pitchers who need rest
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.25} sx={{ gridColumn: "1 / -1" }}>
|
</label>
|
||||||
<Button variant="contained" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
<div className="md:col-span-2 flex flex-wrap gap-3">
|
||||||
|
<Button disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
||||||
Generate Suggested Lineup
|
Generate Suggested Lineup
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outlined" color="inherit" disabled={!generated} onClick={() => void save()}>
|
<Button variant="secondary" disabled={!generated} onClick={() => void save()}>
|
||||||
Save Lineup
|
Save Lineup
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(2, 1fr)" }, gap: 1.5, mt: 3 }}>
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||||
{players.map((player) => (
|
{players.map((player) => {
|
||||||
<Card
|
const selected = selectedPlayerIds.includes(player._id);
|
||||||
key={player._id}
|
return (
|
||||||
elevation={0}
|
<Card
|
||||||
sx={{
|
key={player._id}
|
||||||
border: selectedPlayerIds.includes(player._id) ? "1px solid" : "1px solid rgba(15, 38, 47, 0.08)",
|
className={selected ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20" : ""}
|
||||||
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" }}>
|
<CardContent className="flex items-start justify-between gap-4 pt-6">
|
||||||
<Checkbox
|
<label className="flex flex-1 cursor-pointer items-start gap-4">
|
||||||
checked={selectedPlayerIds.includes(player._id)}
|
<input
|
||||||
onChange={(event) =>
|
type="checkbox"
|
||||||
setSelectedPlayerIds((current) =>
|
checked={selected}
|
||||||
event.target.checked ? [...current, player._id] : current.filter((id) => id !== player._id)
|
onChange={(event) =>
|
||||||
)
|
setSelectedPlayerIds((current) =>
|
||||||
}
|
event.target.checked
|
||||||
/>
|
? [...current, player._id]
|
||||||
<Box>
|
: current.filter((id) => id !== player._id)
|
||||||
<Typography variant="h6">{formatPlayerName(player.firstName, player.lastName)}</Typography>
|
)
|
||||||
<Typography color="text.secondary">
|
}
|
||||||
#{player.jerseyNumber} {player.primaryPosition}
|
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||||
</Typography>
|
/>
|
||||||
</Box>
|
<span>
|
||||||
</Stack>
|
<span className="block text-lg font-semibold">
|
||||||
<StatusBadge status={player.pitcherAvailability.status} />
|
{formatPlayerName(player.firstName, player.lastName)}
|
||||||
</CardContent>
|
</span>
|
||||||
</Card>
|
<span className="mt-1 block text-sm text-muted-foreground">
|
||||||
))}
|
#{player.jerseyNumber} {player.primaryPosition}
|
||||||
</Box>
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<StatusBadge status={player.pitcherAvailability.status} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{generated ? (
|
{generated ? (
|
||||||
<>
|
<>
|
||||||
<SectionCard title="Batting Order" description="Adjust any slot manually before saving.">
|
<SectionCard title="Batting Order" description="Adjust any slot manually before saving.">
|
||||||
<Stack divider={<Divider flexItem />} spacing={0}>
|
<div className="divide-y divide-border">
|
||||||
{generated.battingOrder.map((slot, index) => (
|
{generated.battingOrder.map((slot, index) => (
|
||||||
<Box
|
<div key={`${slot.playerId}-${index}`} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center">
|
||||||
key={`${slot.playerId}-${index}`}
|
<div>
|
||||||
sx={{
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
display: "grid",
|
Spot
|
||||||
gridTemplateColumns: { xs: "1fr", md: "180px 1fr" },
|
</p>
|
||||||
alignItems: "center",
|
<p className="mt-1 text-lg font-semibold">{index + 1}</p>
|
||||||
gap: 2,
|
</div>
|
||||||
py: 2
|
<select
|
||||||
}}
|
className="field-select"
|
||||||
>
|
|
||||||
<Typography variant="h6">Spot {index + 1}</Typography>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
value={slot.playerId}
|
value={slot.playerId}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
||||||
@@ -210,39 +213,40 @@ export function LineupClient() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedPlayers.map((player) => (
|
{selectedPlayers.map((player) => (
|
||||||
<MenuItem key={player._id} value={player._id}>
|
<option key={player._id} value={player._id}>
|
||||||
{formatPlayerName(player.firstName, player.lastName)}
|
{formatPlayerName(player.firstName, player.lastName)}
|
||||||
</MenuItem>
|
</option>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</select>
|
||||||
</Box>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Defensive Alignment" description="Assignments follow position preferences, but every field is editable.">
|
<SectionCard
|
||||||
<Stack divider={<Divider flexItem />} spacing={0}>
|
title="Defensive Alignment"
|
||||||
|
description="Assignments follow position preferences, but every field is editable."
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
{FIELD_POSITIONS.map((position) => {
|
{FIELD_POSITIONS.map((position) => {
|
||||||
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
||||||
return (
|
return (
|
||||||
<Box
|
<div key={position} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center">
|
||||||
key={position}
|
<div>
|
||||||
sx={{
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
display: "grid",
|
Position
|
||||||
gridTemplateColumns: { xs: "1fr", md: "180px 1fr" },
|
</p>
|
||||||
alignItems: "center",
|
<p className="mt-1 text-lg font-semibold">{position}</p>
|
||||||
gap: 2,
|
</div>
|
||||||
py: 2
|
<select
|
||||||
}}
|
className="field-select"
|
||||||
>
|
|
||||||
<Typography variant="h6">{position}</Typography>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
value={assignment?.playerId ?? ""}
|
value={assignment?.playerId ?? ""}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
||||||
if (!nextPlayer) return;
|
if (!nextPlayer) return;
|
||||||
const nextAssignments = generated.defensiveAssignments.filter((entry) => entry.position !== position);
|
const nextAssignments = generated.defensiveAssignments.filter(
|
||||||
|
(entry) => entry.position !== position
|
||||||
|
);
|
||||||
nextAssignments.push({
|
nextAssignments.push({
|
||||||
playerId: nextPlayer._id,
|
playerId: nextPlayer._id,
|
||||||
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
|
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
|
||||||
@@ -254,25 +258,23 @@ export function LineupClient() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="">Unassigned</MenuItem>
|
<option value="">Unassigned</option>
|
||||||
{selectedPlayers.map((player) => (
|
{selectedPlayers.map((player) => (
|
||||||
<MenuItem key={player._id} value={player._id}>
|
<option key={player._id} value={player._id}>
|
||||||
{formatPlayerName(player.firstName, player.lastName)}
|
{formatPlayerName(player.firstName, player.lastName)}
|
||||||
</MenuItem>
|
</option>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</select>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</div>
|
||||||
{generated.notes.length > 0 ? (
|
{generated.notes.length > 0 ? (
|
||||||
<Box sx={{ mt: 2, pl: 2, borderLeft: "4px solid", borderColor: "warning.main" }}>
|
<div className="rounded-[1.5rem] border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
{generated.notes.map((note) => (
|
{generated.notes.map((note) => (
|
||||||
<Typography key={note} color="text.secondary">
|
<p key={note}>{note}</p>
|
||||||
{note}
|
|
||||||
</Typography>
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Button, Checkbox, FormControlLabel, MenuItem, Stack, TextField } from "@mui/material";
|
|
||||||
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
|
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import type { PlayerInput, PlayerRecord } from "@/lib/types";
|
import type { PlayerInput, PlayerRecord } from "@/lib/types";
|
||||||
|
|
||||||
const defaultForm: PlayerInput = {
|
const defaultForm: PlayerInput = {
|
||||||
@@ -46,88 +48,82 @@ export function PlayerForm({
|
|||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
<TextField label="First Name" value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
<label>
|
||||||
<TextField label="Last Name" value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
|
<span className="field-label">First Name</span>
|
||||||
<TextField
|
<Input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
||||||
label="Jersey #"
|
</label>
|
||||||
type="number"
|
<label>
|
||||||
value={value.jerseyNumber}
|
<span className="field-label">Last Name</span>
|
||||||
onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })}
|
<Input value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
|
||||||
/>
|
</label>
|
||||||
<TextField
|
<label>
|
||||||
select
|
<span className="field-label">Jersey #</span>
|
||||||
label="Bats / Throws"
|
<Input type="number" value={value.jerseyNumber} onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })} />
|
||||||
value={value.batsThrows}
|
</label>
|
||||||
onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}
|
<label>
|
||||||
>
|
<span className="field-label">Bats / Throws</span>
|
||||||
{HANDEDNESS_OPTIONS.map((option) => (
|
<select className="field-select" value={value.batsThrows} onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}>
|
||||||
<MenuItem key={option} value={option}>
|
{HANDEDNESS_OPTIONS.map((option) => (
|
||||||
{option}
|
<option key={option} value={option}>
|
||||||
</MenuItem>
|
{option}
|
||||||
))}
|
</option>
|
||||||
</TextField>
|
))}
|
||||||
<TextField
|
</select>
|
||||||
select
|
</label>
|
||||||
label="Primary Position"
|
<label>
|
||||||
value={value.primaryPosition}
|
<span className="field-label">Primary Position</span>
|
||||||
onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })}
|
<select className="field-select" value={value.primaryPosition} onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })}>
|
||||||
>
|
{POSITIONS.map((position) => (
|
||||||
{POSITIONS.map((position) => (
|
<option key={position} value={position}>
|
||||||
<MenuItem key={position} value={position}>
|
{position}
|
||||||
{position}
|
</option>
|
||||||
</MenuItem>
|
))}
|
||||||
))}
|
</select>
|
||||||
</TextField>
|
</label>
|
||||||
<TextField
|
<label>
|
||||||
select
|
<span className="field-label">Secondary Position</span>
|
||||||
label="Secondary Position"
|
<select
|
||||||
value={value.secondaryPosition ?? ""}
|
className="field-select"
|
||||||
onChange={(event) =>
|
value={value.secondaryPosition ?? ""}
|
||||||
onChange({ ...value, secondaryPosition: event.target.value ? (event.target.value as PlayerInput["secondaryPosition"]) : null })
|
onChange={(event) => onChange({ ...value, secondaryPosition: event.target.value ? (event.target.value as PlayerInput["secondaryPosition"]) : null })}
|
||||||
}
|
>
|
||||||
>
|
<option value="">None</option>
|
||||||
<MenuItem value="">None</MenuItem>
|
{POSITIONS.map((position) => (
|
||||||
{POSITIONS.map((position) => (
|
<option key={position} value={position}>
|
||||||
<MenuItem key={position} value={position}>
|
{position}
|
||||||
{position}
|
</option>
|
||||||
</MenuItem>
|
))}
|
||||||
))}
|
</select>
|
||||||
</TextField>
|
</label>
|
||||||
<TextField
|
<label>
|
||||||
select
|
<span className="field-label">Tertiary Position</span>
|
||||||
label="Tertiary Position"
|
<select
|
||||||
value={value.tertiaryPosition ?? ""}
|
className="field-select"
|
||||||
onChange={(event) =>
|
value={value.tertiaryPosition ?? ""}
|
||||||
onChange({ ...value, tertiaryPosition: event.target.value ? (event.target.value as PlayerInput["tertiaryPosition"]) : null })
|
onChange={(event) => onChange({ ...value, tertiaryPosition: event.target.value ? (event.target.value as PlayerInput["tertiaryPosition"]) : null })}
|
||||||
}
|
>
|
||||||
>
|
<option value="">None</option>
|
||||||
<MenuItem value="">None</MenuItem>
|
{POSITIONS.map((position) => (
|
||||||
{POSITIONS.map((position) => (
|
<option key={position} value={position}>
|
||||||
<MenuItem key={position} value={position}>
|
{position}
|
||||||
{position}
|
</option>
|
||||||
</MenuItem>
|
))}
|
||||||
))}
|
</select>
|
||||||
</TextField>
|
</label>
|
||||||
<Stack sx={{ justifyContent: "center" }}>
|
<label className="flex items-center gap-3 rounded-2xl border border-border bg-white/70 px-4 py-3 text-sm font-medium">
|
||||||
<FormControlLabel
|
<input type="checkbox" checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} className="h-4 w-4 rounded border-border text-primary focus:ring-primary" />
|
||||||
control={<Checkbox checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} />}
|
Active for selection
|
||||||
label="Active for selection"
|
</label>
|
||||||
/>
|
<label className="md:col-span-2">
|
||||||
</Stack>
|
<span className="field-label">Notes</span>
|
||||||
<TextField
|
<Textarea rows={4} value={value.notes ?? ""} onChange={(event) => onChange({ ...value, notes: event.target.value })} />
|
||||||
label="Notes"
|
</label>
|
||||||
multiline
|
<div className="md:col-span-2">
|
||||||
rows={4}
|
<Button onClick={onSubmit}>
|
||||||
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}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Alert, Box, Button, Card, CardContent, Divider, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { PlayerForm, emptyPlayerForm, toPlayerForm } from "@/components/PlayerForm";
|
import { PlayerForm, emptyPlayerForm, toPlayerForm } from "@/components/PlayerForm";
|
||||||
import { SectionCard } from "@/components/SectionCard";
|
import { SectionCard } from "@/components/SectionCard";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { fetchJson } from "@/lib/client";
|
import { fetchJson } from "@/lib/client";
|
||||||
import { formatPlayerName } from "@/lib/utils";
|
import { formatPlayerName } from "@/lib/utils";
|
||||||
import type { PitchingAppearanceRecord, PlayerInput, PlayerRecord } from "@/lib/types";
|
import type { PitchingAppearanceRecord, PlayerInput, PlayerRecord } from "@/lib/types";
|
||||||
@@ -20,6 +24,7 @@ export function RosterClient() {
|
|||||||
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null);
|
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null);
|
||||||
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
|
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
|
||||||
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
||||||
|
const playerFormRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [appearanceForm, setAppearanceForm] = useState({
|
const [appearanceForm, setAppearanceForm] = useState({
|
||||||
playerId: "",
|
playerId: "",
|
||||||
gameDate: new Date().toISOString().slice(0, 10),
|
gameDate: new Date().toISOString().slice(0, 10),
|
||||||
@@ -60,6 +65,23 @@ export function RosterClient() {
|
|||||||
}
|
}
|
||||||
}, [editingPlayer]);
|
}, [editingPlayer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingPlayerId) {
|
||||||
|
playerFormRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
}, [editingPlayerId]);
|
||||||
|
|
||||||
|
function startEditingPlayer(playerId: string) {
|
||||||
|
setEditingPlayerId(playerId);
|
||||||
|
setMessage(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopEditingPlayer() {
|
||||||
|
setEditingPlayerId(null);
|
||||||
|
setPlayerForm(emptyPlayerForm());
|
||||||
|
}
|
||||||
|
|
||||||
async function submitPlayer() {
|
async function submitPlayer() {
|
||||||
try {
|
try {
|
||||||
const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players";
|
const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players";
|
||||||
@@ -116,135 +138,175 @@ export function RosterClient() {
|
|||||||
onConfirm={() => (playerPendingDelete ? void deletePlayer(playerPendingDelete) : undefined)}
|
onConfirm={() => (playerPendingDelete ? void deletePlayer(playerPendingDelete) : undefined)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{message ? <Alert severity="success">{message}</Alert> : null}
|
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||||
|
|
||||||
|
<div ref={playerFormRef}>
|
||||||
|
<SectionCard
|
||||||
|
title={editingPlayerId ? "Edit Player" : "Add Player"}
|
||||||
|
description="Keep player details simple so a coach can update them quickly before game time."
|
||||||
|
>
|
||||||
|
{editingPlayer ? (
|
||||||
|
<Alert tone="info" className="flex items-center justify-between gap-4">
|
||||||
|
<span>Editing {formatPlayerName(editingPlayer.firstName, editingPlayer.lastName)}.</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={stopEditingPlayer}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<PlayerForm
|
||||||
|
value={playerForm}
|
||||||
|
onChange={setPlayerForm}
|
||||||
|
onSubmit={() => void submitPlayer()}
|
||||||
|
submitLabel={editingPlayerId ? "Save Changes" : "Add Player"}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={editingPlayerId ? "Edit Player" : "Add Player"}
|
title="Pitch Count Log"
|
||||||
description="Keep player details simple so a coach can update them quickly before game time."
|
description="Record the latest outing to keep rest recommendations accurate."
|
||||||
>
|
>
|
||||||
<PlayerForm value={playerForm} onChange={setPlayerForm} onSubmit={() => void submitPlayer()} submitLabel={editingPlayerId ? "Save Changes" : "Add Player"} />
|
<div className="grid gap-5 md:grid-cols-3">
|
||||||
</SectionCard>
|
<label>
|
||||||
|
<span className="field-label">Player</span>
|
||||||
<SectionCard title="Pitch Count Log" description="Record the latest outing to keep rest recommendations accurate.">
|
<select
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 2 }}>
|
className="field-select"
|
||||||
<TextField
|
value={appearanceForm.playerId}
|
||||||
select
|
onChange={(event) =>
|
||||||
label="Player"
|
setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))
|
||||||
value={appearanceForm.playerId}
|
}
|
||||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))}
|
>
|
||||||
>
|
{players.map((player) => (
|
||||||
{players.map((player) => (
|
<option key={player._id} value={player._id}>
|
||||||
<MenuItem key={player._id} value={player._id}>
|
{formatPlayerName(player.firstName, player.lastName)}
|
||||||
{formatPlayerName(player.firstName, player.lastName)}
|
</option>
|
||||||
</MenuItem>
|
))}
|
||||||
))}
|
</select>
|
||||||
</TextField>
|
</label>
|
||||||
<TextField
|
<label>
|
||||||
label="Game Date"
|
<span className="field-label">Game Date</span>
|
||||||
type="date"
|
<Input
|
||||||
value={appearanceForm.gameDate}
|
type="date"
|
||||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))}
|
value={appearanceForm.gameDate}
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
onChange={(event) =>
|
||||||
/>
|
setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))
|
||||||
<TextField
|
}
|
||||||
label="Pitches Thrown"
|
/>
|
||||||
type="number"
|
</label>
|
||||||
value={appearanceForm.pitchCount}
|
<label>
|
||||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, pitchCount: Number(event.target.value) }))}
|
<span className="field-label">Pitches Thrown</span>
|
||||||
/>
|
<Input
|
||||||
<TextField
|
type="number"
|
||||||
label="Notes"
|
value={appearanceForm.pitchCount}
|
||||||
multiline
|
onChange={(event) =>
|
||||||
rows={3}
|
setAppearanceForm((current) => ({
|
||||||
value={appearanceForm.notes}
|
...current,
|
||||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, notes: event.target.value }))}
|
pitchCount: Number(event.target.value)
|
||||||
sx={{ gridColumn: "1 / -1" }}
|
}))
|
||||||
/>
|
}
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
/>
|
||||||
<Button variant="contained" onClick={() => void logAppearance()} disabled={players.length === 0}>
|
</label>
|
||||||
|
<label className="md:col-span-3">
|
||||||
|
<span className="field-label">Notes</span>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={appearanceForm.notes}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAppearanceForm((current) => ({ ...current, notes: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<Button onClick={() => void logAppearance()} disabled={players.length === 0}>
|
||||||
Save Pitching Appearance
|
Save Pitching Appearance
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Current Roster" description="Tap edit for fast changes and check pitcher status at a glance.">
|
<SectionCard
|
||||||
|
title="Current Roster"
|
||||||
|
description="Tap edit for fast changes and check pitcher status at a glance."
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Typography color="text.secondary">Loading roster...</Typography>
|
<p className="text-sm text-muted-foreground">Loading roster...</p>
|
||||||
) : players.length > 0 ? (
|
) : players.length > 0 ? (
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "repeat(2, 1fr)" }, gap: 2 }}>
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
{players.map((player) => (
|
{players.map((player) => (
|
||||||
<Card key={player._id} elevation={0} sx={{ border: "1px solid rgba(15, 38, 47, 0.08)" }}>
|
<Card
|
||||||
<CardContent sx={{ display: "grid", gap: 2 }}>
|
key={player._id}
|
||||||
<Box
|
className={
|
||||||
sx={{
|
editingPlayerId === player._id
|
||||||
display: "flex",
|
? "border-secondary/60 bg-secondary/5 ring-1 ring-secondary/30"
|
||||||
flexDirection: { xs: "column", sm: "row" },
|
: ""
|
||||||
justifyContent: "space-between",
|
}
|
||||||
gap: 1.5
|
>
|
||||||
}}
|
<CardContent className="grid gap-5 pt-6">
|
||||||
>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="h6">
|
<h3 className="text-xl font-semibold">
|
||||||
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
|
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
|
||||||
</Typography>
|
</h3>
|
||||||
<Typography color="text.secondary">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{player.primaryPosition}
|
{player.primaryPosition}
|
||||||
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
|
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
|
||||||
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
|
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
|
||||||
</Typography>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
<StatusBadge status={player.pitcherAvailability.status} />
|
<StatusBadge status={player.pitcherAvailability.status} />
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Divider />
|
<div className="grid gap-4 rounded-[1.5rem] bg-muted/60 p-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" }, gap: 1.5 }}>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<Box>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
Bats/Throws
|
Bats/Throws
|
||||||
</Typography>
|
</p>
|
||||||
<Typography>{player.batsThrows}</Typography>
|
<p className="mt-1 text-sm font-medium">{player.batsThrows}</p>
|
||||||
</Box>
|
</div>
|
||||||
<Box>
|
<div>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
Status
|
Status
|
||||||
</Typography>
|
</p>
|
||||||
<Typography>{player.isActive ? "Active" : "Inactive"}</Typography>
|
<p className="mt-1 text-sm font-medium">
|
||||||
</Box>
|
{player.isActive ? "Active" : "Inactive"}
|
||||||
<Box>
|
</p>
|
||||||
<Typography variant="caption" color="text.secondary">
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
Pitching
|
Pitching
|
||||||
</Typography>
|
</p>
|
||||||
<Typography>
|
<p className="mt-1 text-sm font-medium">
|
||||||
{player.pitcherAvailability.lastPitchedDate
|
{player.pitcherAvailability.lastPitchedDate
|
||||||
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
|
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
|
||||||
: "No history"}
|
: "No history"}
|
||||||
</Typography>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{player.notes ? (
|
{player.notes ? (
|
||||||
<Box sx={{ p: 1.5, borderRadius: 3, bgcolor: "rgba(15, 92, 77, 0.06)" }}>
|
<div className="rounded-[1.5rem] border border-border/70 bg-white/70 px-4 py-3 text-sm text-muted-foreground">
|
||||||
<Typography color="text.secondary">{player.notes}</Typography>
|
{player.notes}
|
||||||
</Box>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Stack direction="row" spacing={1.25}>
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button variant="outlined" color="inherit" onClick={() => setEditingPlayerId(player._id)}>
|
<Button variant="secondary" onClick={() => startEditingPlayer(player._id)}>
|
||||||
Edit
|
{editingPlayerId === player._id ? "Editing" : "Edit"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" color="error" onClick={() => setPlayerPendingDelete(player)}>
|
<Button variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="No players yet" description="Add your first player above to start building the roster." />
|
<EmptyState
|
||||||
|
title="No players yet"
|
||||||
|
description="Add your first player above to start building the roster."
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, CardContent, Stack, Typography } from "@mui/material";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
export function SectionCard({
|
export function SectionCard({
|
||||||
title,
|
title,
|
||||||
@@ -10,25 +10,12 @@ export function SectionCard({
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card>
|
||||||
elevation={0}
|
<CardHeader className="pb-4">
|
||||||
sx={{
|
<CardTitle>{title}</CardTitle>
|
||||||
border: "1px solid rgba(15, 38, 47, 0.08)",
|
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||||
backgroundColor: "rgba(255,255,255,0.8)",
|
</CardHeader>
|
||||||
backdropFilter: "blur(10px)"
|
<CardContent className="space-y-5">{children}</CardContent>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Alert, Box, Button, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField } from "@mui/material";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { SectionCard } from "@/components/SectionCard";
|
import { SectionCard } from "@/components/SectionCard";
|
||||||
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { fetchJson } from "@/lib/client";
|
import { fetchJson } from "@/lib/client";
|
||||||
import { DEFAULT_PITCHING_RULES } from "@/lib/constants";
|
import { DEFAULT_PITCHING_RULES } from "@/lib/constants";
|
||||||
import type { PitchingRuleRecord } from "@/lib/types";
|
import type { PitchingRuleRecord } from "@/lib/types";
|
||||||
@@ -51,78 +53,68 @@ export function SettingsClient() {
|
|||||||
title="Pitching Rest Rules"
|
title="Pitching Rest Rules"
|
||||||
subtitle="Tune the rest windows your league uses so eligibility stays accurate and easy to trust."
|
subtitle="Tune the rest windows your league uses so eligibility stays accurate and easy to trust."
|
||||||
>
|
>
|
||||||
{message ? <Alert severity="success">{message}</Alert> : null}
|
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||||
|
|
||||||
<SectionCard title="Rule Table" description="Each row maps a pitch-count range to required rest days.">
|
<SectionCard title="Rule Table" description="Each row maps a pitch-count range to required rest days.">
|
||||||
<Box sx={{ overflowX: "auto" }}>
|
<div className="overflow-hidden rounded-[1.75rem] border border-border bg-white/70">
|
||||||
<Table size="small">
|
<div className="grid grid-cols-3 gap-4 border-b border-border bg-muted/60 px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<TableHead>
|
<span>Min Pitches</span>
|
||||||
<TableRow>
|
<span>Max Pitches</span>
|
||||||
<TableCell>Min Pitches</TableCell>
|
<span>Rest Days</span>
|
||||||
<TableCell>Max Pitches</TableCell>
|
</div>
|
||||||
<TableCell>Rest Days</TableCell>
|
<div className="divide-y divide-border">
|
||||||
</TableRow>
|
{rules.map((rule, index) => (
|
||||||
</TableHead>
|
<div key={`${rule.minPitches}-${index}`} className="grid grid-cols-3 gap-4 px-4 py-4">
|
||||||
<TableBody>
|
<Input
|
||||||
{rules.map((rule, index) => (
|
type="number"
|
||||||
<TableRow key={`${rule.minPitches}-${index}`}>
|
value={rule.minPitches}
|
||||||
<TableCell>
|
onChange={(event) =>
|
||||||
<TextField
|
setRules((current) =>
|
||||||
type="number"
|
current.map((entry, entryIndex) =>
|
||||||
value={rule.minPitches}
|
entryIndex === index ? { ...entry, minPitches: Number(event.target.value) } : entry
|
||||||
onChange={(event) =>
|
)
|
||||||
setRules((current) =>
|
)
|
||||||
current.map((entry, entryIndex) =>
|
}
|
||||||
entryIndex === index ? { ...entry, minPitches: Number(event.target.value) } : entry
|
/>
|
||||||
)
|
<Input
|
||||||
)
|
type="number"
|
||||||
}
|
value={rule.maxPitches ?? ""}
|
||||||
/>
|
placeholder="No max"
|
||||||
</TableCell>
|
onChange={(event) =>
|
||||||
<TableCell>
|
setRules((current) =>
|
||||||
<TextField
|
current.map((entry, entryIndex) =>
|
||||||
type="number"
|
entryIndex === index
|
||||||
value={rule.maxPitches ?? ""}
|
? { ...entry, maxPitches: event.target.value ? Number(event.target.value) : null }
|
||||||
placeholder="No max"
|
: entry
|
||||||
onChange={(event) =>
|
)
|
||||||
setRules((current) =>
|
)
|
||||||
current.map((entry, entryIndex) =>
|
}
|
||||||
entryIndex === index
|
/>
|
||||||
? { ...entry, maxPitches: event.target.value ? Number(event.target.value) : null }
|
<Input
|
||||||
: entry
|
type="number"
|
||||||
)
|
value={rule.requiredRestDays}
|
||||||
)
|
onChange={(event) =>
|
||||||
}
|
setRules((current) =>
|
||||||
/>
|
current.map((entry, entryIndex) =>
|
||||||
</TableCell>
|
entryIndex === index
|
||||||
<TableCell>
|
? { ...entry, requiredRestDays: Number(event.target.value) }
|
||||||
<TextField
|
: entry
|
||||||
type="number"
|
)
|
||||||
value={rule.requiredRestDays}
|
)
|
||||||
onChange={(event) =>
|
}
|
||||||
setRules((current) =>
|
/>
|
||||||
current.map((entry, entryIndex) =>
|
</div>
|
||||||
entryIndex === index ? { ...entry, requiredRestDays: Number(event.target.value) } : entry
|
))}
|
||||||
)
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.25}>
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button variant="outlined" color="inherit" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
|
<Button variant="secondary" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
|
||||||
Reset to Example Rules
|
Reset to Example Rules
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" onClick={() => void saveRules()}>
|
<Button onClick={() => void saveRules()}>Save Rules</Button>
|
||||||
Save Rules
|
</div>
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { Chip } from "@mui/material";
|
|
||||||
import type { PitcherAvailability } from "@/lib/types";
|
import type { PitcherAvailability } from "@/lib/types";
|
||||||
import { statusLabel } from "@/lib/utils";
|
import { cn, statusLabel } from "@/lib/utils";
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: PitcherAvailability }) {
|
export function StatusBadge({ status }: { status: PitcherAvailability }) {
|
||||||
const color = status === "available" ? "success" : status === "needs-rest" ? "error" : "default";
|
return (
|
||||||
|
<span
|
||||||
return <Chip label={statusLabel(status)} color={color} variant={status === "unknown" ? "outlined" : "filled"} size="small" />;
|
className={cn(
|
||||||
|
"inline-flex rounded-full px-3 py-1 text-xs font-semibold",
|
||||||
|
status === "available" && "bg-emerald-100 text-emerald-800",
|
||||||
|
status === "needs-rest" && "bg-red-100 text-red-800",
|
||||||
|
status === "unknown" && "bg-slate-100 text-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel(status)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Alert({
|
||||||
|
className,
|
||||||
|
tone = "default",
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & { tone?: "default" | "success" | "error" | "info" }) {
|
||||||
|
const toneClasses =
|
||||||
|
tone === "success"
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-900"
|
||||||
|
: tone === "error"
|
||||||
|
? "border-red-200 bg-red-50 text-red-900"
|
||||||
|
: tone === "info"
|
||||||
|
? "border-sky-200 bg-sky-50 text-sky-900"
|
||||||
|
: "border-border bg-card text-foreground";
|
||||||
|
|
||||||
|
return <div className={cn("rounded-2xl border px-4 py-3 text-sm shadow-sm", toneClasses, className)} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground shadow-panel hover:bg-primary/90",
|
||||||
|
secondary: "bg-card text-foreground border border-border hover:bg-muted",
|
||||||
|
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground shadow-panel hover:bg-destructive/90"
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-11 px-5",
|
||||||
|
sm: "h-9 px-4 text-xs",
|
||||||
|
lg: "h-12 px-6",
|
||||||
|
icon: "h-10 w-10 rounded-full"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => (
|
||||||
|
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-3xl border border-white/60 bg-card/90 text-card-foreground shadow-panel backdrop-blur",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return <h3 className={cn("font-display text-2xl font-semibold tracking-tight", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("px-6 pb-6", className)} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 w-full rounded-2xl border border-input bg-white/90 px-4 py-2 text-sm text-foreground shadow-sm transition placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"min-h-[120px] w-full rounded-2xl border border-input bg-white/90 px-4 py-3 text-sm text-foreground shadow-sm transition placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import type { PitcherAvailability } from "@/lib/types";
|
import type { PitcherAvailability } from "@/lib/types";
|
||||||
|
|
||||||
export function cn(...parts: Array<string | false | null | undefined>) {
|
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
return parts.filter(Boolean).join(" ");
|
return twMerge(clsx(parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPlayerName(firstName: string, lastName: string) {
|
export function formatPlayerName(firstName: string, lastName: string) {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ["./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "1rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1280px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(32 22% 86%)",
|
||||||
|
input: "hsl(32 22% 86%)",
|
||||||
|
ring: "hsl(157 63% 27%)",
|
||||||
|
background: "hsl(42 43% 96%)",
|
||||||
|
foreground: "hsl(205 32% 12%)",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(157 63% 27%)",
|
||||||
|
foreground: "hsl(48 100% 98%)"
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(25 69% 54%)",
|
||||||
|
foreground: "hsl(48 100% 98%)"
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(36 28% 92%)",
|
||||||
|
foreground: "hsl(211 15% 42%)"
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(194 52% 18%)",
|
||||||
|
foreground: "hsl(48 100% 98%)"
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(48 100% 99%)",
|
||||||
|
foreground: "hsl(205 32% 12%)"
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(6 72% 54%)",
|
||||||
|
foreground: "hsl(48 100% 98%)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
xl: "1rem",
|
||||||
|
"2xl": "1.5rem",
|
||||||
|
"3xl": "2rem"
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glow: "0 24px 80px rgba(13, 52, 63, 0.15)",
|
||||||
|
panel: "0 10px 30px rgba(23, 34, 45, 0.08)"
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Inter"', '"Segoe UI"', "sans-serif"],
|
||||||
|
display: ['"Cal Sans"', '"Avenir Next"', '"Segoe UI"', "sans-serif"]
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
"hero-grid":
|
||||||
|
"linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.08) 1px, transparent 1px)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user