Updating
This commit is contained in:
@@ -16,7 +16,6 @@ Mobile-friendly roster and lineup planning app for Little League baseball. It in
|
|||||||
- See pitcher availability as `Available`, `Needs Rest`, or `Unknown`
|
- See pitcher availability as `Available`, `Needs Rest`, or `Unknown`
|
||||||
- Generate suggested batting orders and defensive assignments
|
- Generate suggested batting orders and defensive assignments
|
||||||
- Manually adjust generated lineups before saving
|
- Manually adjust generated lineups before saving
|
||||||
- Seed sample data for a quick demo
|
|
||||||
- Placeholder auth utility for future expansion
|
- Placeholder auth utility for future expansion
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -50,30 +49,11 @@ npm run dev
|
|||||||
|
|
||||||
5. Open `http://localhost:3000`.
|
5. Open `http://localhost:3000`.
|
||||||
|
|
||||||
## Sample Data
|
|
||||||
|
|
||||||
You can load sample data in two ways:
|
|
||||||
|
|
||||||
- Click `Load Sample Team` on the dashboard
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run seed
|
|
||||||
```
|
|
||||||
|
|
||||||
The seed script adds:
|
|
||||||
|
|
||||||
- Sample players
|
|
||||||
- Default pitching rest rules
|
|
||||||
- Sample pitching appearances
|
|
||||||
- One sample saved game/lineup
|
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- `npm run dev` starts the development server
|
- `npm run dev` starts the development server
|
||||||
- `npm run build` builds the app
|
- `npm run build` builds the app
|
||||||
- `npm run start` runs the production build
|
- `npm run start` runs the production build
|
||||||
- `npm run seed` seeds the database if it is empty
|
|
||||||
|
|
||||||
## Data Models
|
## Data Models
|
||||||
|
|
||||||
|
|||||||
Generated
+954
-2
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,13 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint"
|
||||||
"seed": "node --import tsx src/scripts/seed.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^9.0.0",
|
||||||
|
"@mui/material": "^9.0.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"mongoose": "^8.14.3",
|
"mongoose": "^8.14.3",
|
||||||
"next": "^15.3.2",
|
"next": "^15.3.2",
|
||||||
|
|||||||
@@ -31,17 +31,15 @@ export async function GET(request: Request) {
|
|||||||
gameDate
|
gameDate
|
||||||
);
|
);
|
||||||
|
|
||||||
const response: DashboardResponse = {
|
let availablePitchers = 0;
|
||||||
rosterCount: players.length,
|
let needsRest = 0;
|
||||||
activePlayers: players.filter((player) => player.isActive).length,
|
let unknownPitchers = 0;
|
||||||
availablePitchers: 0,
|
|
||||||
needsRest: 0,
|
const recentPitching = players.map((player) => {
|
||||||
unknownPitchers: 0,
|
|
||||||
recentPitching: players.map((player) => {
|
|
||||||
const summary = availabilityMap[String(player._id)];
|
const summary = availabilityMap[String(player._id)];
|
||||||
if (summary.status === "available") response.availablePitchers += 1;
|
if (summary.status === "available") availablePitchers += 1;
|
||||||
if (summary.status === "needs-rest") response.needsRest += 1;
|
if (summary.status === "needs-rest") needsRest += 1;
|
||||||
if (summary.status === "unknown") response.unknownPitchers += 1;
|
if (summary.status === "unknown") unknownPitchers += 1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playerId: String(player._id),
|
playerId: String(player._id),
|
||||||
@@ -52,7 +50,15 @@ export async function GET(request: Request) {
|
|||||||
lastPitchedDate: summary.lastPitchedDate,
|
lastPitchedDate: summary.lastPitchedDate,
|
||||||
pitchCount: summary.lastPitchCount
|
pitchCount: summary.lastPitchCount
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const response: DashboardResponse = {
|
||||||
|
rosterCount: players.length,
|
||||||
|
activePlayers: players.filter((player) => player.isActive).length,
|
||||||
|
availablePitchers,
|
||||||
|
needsRest,
|
||||||
|
unknownPitchers,
|
||||||
|
recentPitching
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(serialize(response));
|
return NextResponse.json(serialize(response));
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { handleApiError } from "@/app/api/_utils";
|
|
||||||
import { seedDatabase } from "@/lib/seed";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
const result = await seedDatabase();
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
return handleApiError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #f4efe3;
|
--font-sans: "Avenir Next", "Segoe UI", "Trebuchet MS", sans-serif;
|
||||||
--surface: rgba(255, 252, 246, 0.94);
|
--font-display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
|
||||||
--surface-strong: #fffdf8;
|
|
||||||
--ink: #152033;
|
|
||||||
--muted: #5e687c;
|
|
||||||
--line: rgba(21, 32, 51, 0.1);
|
|
||||||
--accent: #0d6a5b;
|
|
||||||
--accent-strong: #094f44;
|
|
||||||
--warning: #b87510;
|
|
||||||
--danger: #b53a2c;
|
|
||||||
--success: #177245;
|
|
||||||
--shadow: 0 20px 45px rgba(21, 32, 51, 0.12);
|
|
||||||
--radius-lg: 24px;
|
|
||||||
--radius-md: 18px;
|
|
||||||
--radius-sm: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -27,408 +14,14 @@ html {
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: var(--ink);
|
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(13, 106, 91, 0.2), transparent 32%),
|
radial-gradient(circle at top left, rgba(15, 92, 77, 0.18), transparent 28%),
|
||||||
radial-gradient(circle at top right, rgba(184, 117, 16, 0.18), transparent 28%),
|
radial-gradient(circle at top right, rgba(207, 111, 46, 0.18), transparent 24%),
|
||||||
linear-gradient(180deg, #f8f2e7 0%, var(--bg) 48%, #efe7d5 100%);
|
linear-gradient(180deg, #fbf7ef 0%, #f4efe3 44%, #efe5d4 100%);
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell {
|
|
||||||
width: min(1120px, calc(100% - 24px));
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px 0 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 28px;
|
|
||||||
border-radius: 28px;
|
|
||||||
background: linear-gradient(135deg, rgba(14, 65, 92, 0.96), rgba(13, 106, 91, 0.92));
|
|
||||||
color: white;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy,
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-copy {
|
|
||||||
margin-top: 12px;
|
|
||||||
max-width: 50rem;
|
|
||||||
color: rgba(255, 255, 255, 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: rgba(255, 255, 255, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
margin: 18px 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-active {
|
|
||||||
background: var(--surface-strong);
|
|
||||||
color: var(--ink);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading h2,
|
|
||||||
.list-row h3,
|
|
||||||
.empty-state h3,
|
|
||||||
.player-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-heading p,
|
|
||||||
.empty-state p,
|
|
||||||
.list-row p,
|
|
||||||
.muted-copy {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid,
|
|
||||||
.card-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface-strong);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card span {
|
|
||||||
display: block;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card-success strong {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card-warning strong {
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-row,
|
|
||||||
.picker-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-meta {
|
|
||||||
display: grid;
|
|
||||||
justify-items: end;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 108px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-available {
|
|
||||||
background: rgba(23, 114, 69, 0.12);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-needs-rest {
|
|
||||||
background: rgba(181, 58, 44, 0.12);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-unknown {
|
|
||||||
background: rgba(94, 104, 124, 0.12);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
padding: 28px;
|
|
||||||
border: 1px dashed var(--line);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-success {
|
|
||||||
background: rgba(23, 114, 69, 0.12);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-error {
|
|
||||||
background: rgba(181, 58, 44, 0.12);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid label,
|
|
||||||
.rule-table th,
|
|
||||||
.rule-table td {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid label span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.full-width {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid input,
|
|
||||||
.form-grid select,
|
|
||||||
.form-grid textarea,
|
|
||||||
.rule-table input,
|
|
||||||
.list-row select {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: white;
|
|
||||||
padding: 12px 14px;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row span {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row input {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 12px 18px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-secondary {
|
|
||||||
background: white;
|
|
||||||
color: var(--ink);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-danger {
|
|
||||||
background: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-card-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-card-head p,
|
|
||||||
.player-notes,
|
|
||||||
.player-meta dt {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-meta dd {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-table th,
|
|
||||||
.rule-table td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-card input {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-block {
|
|
||||||
margin-top: 16px;
|
|
||||||
border-left: 4px solid var(--warning);
|
|
||||||
padding-left: 14px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.app-shell {
|
|
||||||
width: min(100% - 16px, 1120px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero,
|
|
||||||
.tabs,
|
|
||||||
.form-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero,
|
|
||||||
.list-row,
|
|
||||||
.picker-card,
|
|
||||||
.player-card-head {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-meta {
|
|
||||||
justify-items: start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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 = {
|
||||||
@@ -9,7 +10,9 @@ 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>{children}</body>
|
<body>
|
||||||
|
<AppThemeProvider>{children}</AppThemeProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import type { Route } from "next";
|
|
||||||
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" },
|
||||||
@@ -24,27 +24,95 @@ 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 (
|
||||||
<div className="app-shell">
|
<Container maxWidth="lg" sx={{ py: { xs: 2, md: 4 } }}>
|
||||||
<header className="hero">
|
<Paper
|
||||||
<div>
|
elevation={0}
|
||||||
<p className="eyebrow">Little League Coach Console</p>
|
sx={{
|
||||||
<h1>{title}</h1>
|
p: { xs: 3, md: 4 },
|
||||||
<p className="hero-copy">{subtitle}</p>
|
borderRadius: 7,
|
||||||
</div>
|
color: "common.white",
|
||||||
{actions ? <div className="hero-actions">{actions}</div> : null}
|
background:
|
||||||
</header>
|
"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">
|
<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) => (
|
{navItems.map((item) => (
|
||||||
<Link key={item.href} href={item.href as Route} className={cn("tab", pathname === item.href && "tab-active")}>
|
<Tab
|
||||||
{item.label}
|
key={item.href}
|
||||||
</Link>
|
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)"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</Tabs>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
<main className="page-grid">{children}</main>
|
<Box component="main" sx={{ display: "grid", gap: 2.25, mt: 2.5 }}>
|
||||||
</div>
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"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";
|
||||||
@@ -12,7 +13,6 @@ export function DashboardClient() {
|
|||||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
setLoading(true);
|
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(() => {
|
useEffect(() => {
|
||||||
void loadDashboard();
|
void loadDashboard();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -46,76 +36,75 @@ export function DashboardClient() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
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."
|
||||||
actions={
|
|
||||||
<button className="button button-secondary" onClick={() => void seedSampleData()}>
|
|
||||||
Load Sample Team
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||||
{error ? <div className="banner banner-error">{error}</div> : 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 ? (
|
||||||
<>
|
<>
|
||||||
<section className="stats-grid">
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", lg: "repeat(4, 1fr)" }, gap: 2 }}>
|
||||||
<article className="stat-card">
|
{[
|
||||||
<span>Rostered Players</span>
|
{ label: "Rostered Players", value: data.rosterCount },
|
||||||
<strong>{data.rosterCount}</strong>
|
{ label: "Active Today", value: data.activePlayers },
|
||||||
</article>
|
{ label: "Pitchers Available", value: data.availablePitchers, color: "success.main" },
|
||||||
<article className="stat-card">
|
{ label: "Need Rest", value: data.needsRest, color: "warning.main" }
|
||||||
<span>Active Today</span>
|
].map((item) => (
|
||||||
<strong>{data.activePlayers}</strong>
|
<Card key={item.label} elevation={0} sx={{ border: "1px solid rgba(15, 38, 47, 0.08)" }}>
|
||||||
</article>
|
<CardContent>
|
||||||
<article className="stat-card stat-card-success">
|
<Typography color="text.secondary" variant="body2">
|
||||||
<span>Pitchers Available</span>
|
{item.label}
|
||||||
<strong>{data.availablePitchers}</strong>
|
</Typography>
|
||||||
</article>
|
<Typography variant="h2" sx={{ mt: 1, color: item.color ?? "text.primary", fontSize: "2.35rem" }}>
|
||||||
<article className="stat-card stat-card-warning">
|
{item.value}
|
||||||
<span>Need Rest</span>
|
</Typography>
|
||||||
<strong>{data.needsRest}</strong>
|
</CardContent>
|
||||||
</article>
|
</Card>
|
||||||
</section>
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<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."
|
||||||
>
|
>
|
||||||
<div className="list-stack">
|
<Stack divider={<Divider flexItem />} spacing={0}>
|
||||||
{data.recentPitching.map((entry) => (
|
{data.recentPitching.map((entry) => (
|
||||||
<article key={entry.playerId} className="list-row">
|
<Box
|
||||||
<div>
|
key={entry.playerId}
|
||||||
<h3>{entry.playerName}</h3>
|
sx={{
|
||||||
<p>
|
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.lastPitchedDate
|
||||||
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
|
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
|
||||||
: "No pitching history recorded yet."}
|
: "No pitching history recorded yet."}
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
<div className="list-meta">
|
<Stack spacing={0.75} sx={{ alignItems: { xs: "flex-start", md: "flex-end" } }}>
|
||||||
<StatusBadge status={entry.status} />
|
<StatusBadge status={entry.status} />
|
||||||
<small>
|
<Typography variant="body2" color="text.secondary">
|
||||||
{entry.daysSinceLastAppearance === null
|
{entry.daysSinceLastAppearance === null
|
||||||
? "No history"
|
? "No history"
|
||||||
: `${entry.daysSinceLastAppearance} day(s) since last outing`}
|
: `${entry.daysSinceLastAppearance} day(s) since last outing`}
|
||||||
</small>
|
</Typography>
|
||||||
</div>
|
</Stack>
|
||||||
</article>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
</SectionCard>
|
</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
|
<EmptyState
|
||||||
title="Roster is empty"
|
title="Roster is empty"
|
||||||
description="Load the sample team to explore the app, then adjust players and pitching records as needed."
|
description="Add your team on the roster screen, then return here to review availability and game-day readiness."
|
||||||
action={
|
|
||||||
<button className="button" onClick={() => void seedSampleData()}>
|
|
||||||
Load Sample Team
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Box, Typography } from "@mui/material";
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -8,10 +10,19 @@ export function EmptyState({
|
|||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="empty-state">
|
<Box
|
||||||
<h3>{title}</h3>
|
sx={{
|
||||||
<p>{description}</p>
|
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}
|
{action}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"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";
|
||||||
@@ -94,44 +95,63 @@ 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 ? <div className="banner banner-success">{message}</div> : null}
|
{message ? <Alert severity="success">{message}</Alert> : null}
|
||||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
{error ? <Alert severity="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.">
|
||||||
<div className="form-grid">
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
|
||||||
<label>
|
<TextField
|
||||||
<span>Game Date</span>
|
label="Game Date"
|
||||||
<input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
|
type="date"
|
||||||
</label>
|
value={gameDate}
|
||||||
<label>
|
onChange={(event) => setGameDate(event.target.value)}
|
||||||
<span>Starting Pitcher</span>
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
<select value={startingPitcherId} onChange={(event) => setStartingPitcherId(event.target.value)}>
|
/>
|
||||||
|
<TextField select label="Starting Pitcher" value={startingPitcherId} onChange={(event) => setStartingPitcherId(event.target.value)}>
|
||||||
{selectablePitchers.map((player) => (
|
{selectablePitchers.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>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
<label className="checkbox-row">
|
<FormControlLabel
|
||||||
<input type="checkbox" checked={showAllPitchers} onChange={(event) => setShowAllPitchers(event.target.checked)} />
|
control={<Checkbox checked={showAllPitchers} onChange={(event) => setShowAllPitchers(event.target.checked)} />}
|
||||||
<span>Show pitchers who need rest</span>
|
label="Show pitchers who need rest"
|
||||||
</label>
|
/>
|
||||||
<div className="full-width action-row">
|
</Box>
|
||||||
<button className="button" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
<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
|
Generate Suggested Lineup
|
||||||
</button>
|
</Button>
|
||||||
<button className="button button-secondary" disabled={!generated} onClick={() => void save()}>
|
<Button variant="outlined" color="inherit" disabled={!generated} onClick={() => void save()}>
|
||||||
Save Lineup
|
Save Lineup
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="picker-grid">
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(2, 1fr)" }, gap: 1.5, mt: 3 }}>
|
||||||
{players.map((player) => (
|
{players.map((player) => (
|
||||||
<label key={player._id} className="picker-card">
|
<Card
|
||||||
<input
|
key={player._id}
|
||||||
type="checkbox"
|
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)}
|
checked={selectedPlayerIds.includes(player._id)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSelectedPlayerIds((current) =>
|
setSelectedPlayerIds((current) =>
|
||||||
@@ -139,28 +159,38 @@ export function LineupClient() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div>
|
<Box>
|
||||||
<strong>{formatPlayerName(player.firstName, player.lastName)}</strong>
|
<Typography variant="h6">{formatPlayerName(player.firstName, player.lastName)}</Typography>
|
||||||
<p>
|
<Typography color="text.secondary">
|
||||||
#{player.jerseyNumber} {player.primaryPosition}
|
#{player.jerseyNumber} {player.primaryPosition}
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
|
</Stack>
|
||||||
<StatusBadge status={player.pitcherAvailability.status} />
|
<StatusBadge status={player.pitcherAvailability.status} />
|
||||||
</label>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
</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.">
|
||||||
<div className="list-stack">
|
<Stack divider={<Divider flexItem />} spacing={0}>
|
||||||
{generated.battingOrder.map((slot, index) => (
|
{generated.battingOrder.map((slot, index) => (
|
||||||
<div key={`${slot.playerId}-${index}`} className="list-row">
|
<Box
|
||||||
<div>
|
key={`${slot.playerId}-${index}`}
|
||||||
<h3>Spot {index + 1}</h3>
|
sx={{
|
||||||
</div>
|
display: "grid",
|
||||||
<select
|
gridTemplateColumns: { xs: "1fr", md: "180px 1fr" },
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
py: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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);
|
||||||
@@ -180,26 +210,34 @@ export function LineupClient() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedPlayers.map((player) => (
|
{selectedPlayers.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>
|
||||||
</div>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Defensive Alignment" description="Assignments follow position preferences, but every field is editable.">
|
<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) => {
|
{FIELD_POSITIONS.map((position) => {
|
||||||
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
||||||
return (
|
return (
|
||||||
<div key={position} className="list-row">
|
<Box
|
||||||
<div>
|
key={position}
|
||||||
<h3>{position}</h3>
|
sx={{
|
||||||
</div>
|
display: "grid",
|
||||||
<select
|
gridTemplateColumns: { xs: "1fr", md: "180px 1fr" },
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
py: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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);
|
||||||
@@ -216,23 +254,25 @@ export function LineupClient() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">Unassigned</option>
|
<MenuItem value="">Unassigned</MenuItem>
|
||||||
{selectedPlayers.map((player) => (
|
{selectedPlayers.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>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Stack>
|
||||||
{generated.notes.length > 0 ? (
|
{generated.notes.length > 0 ? (
|
||||||
<div className="note-block">
|
<Box sx={{ mt: 2, pl: 2, borderLeft: "4px solid", borderColor: "warning.main" }}>
|
||||||
{generated.notes.map((note) => (
|
{generated.notes.map((note) => (
|
||||||
<p key={note}>{note}</p>
|
<Typography key={note} color="text.secondary">
|
||||||
|
{note}
|
||||||
|
</Typography>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"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 type { PlayerInput, PlayerRecord } from "@/lib/types";
|
import type { PlayerInput, PlayerRecord } from "@/lib/types";
|
||||||
|
|
||||||
@@ -45,84 +46,88 @@ export function PlayerForm({
|
|||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="form-grid">
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
|
||||||
<label>
|
<TextField label="First Name" value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
||||||
<span>First Name</span>
|
<TextField label="Last Name" value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
|
||||||
<input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
<TextField
|
||||||
</label>
|
label="Jersey #"
|
||||||
<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"
|
type="number"
|
||||||
value={value.jerseyNumber}
|
value={value.jerseyNumber}
|
||||||
onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })}
|
onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })}
|
||||||
/>
|
/>
|
||||||
</label>
|
<TextField
|
||||||
<label>
|
select
|
||||||
<span>Bats / Throws</span>
|
label="Bats / Throws"
|
||||||
<select value={value.batsThrows} onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}>
|
value={value.batsThrows}
|
||||||
|
onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}
|
||||||
|
>
|
||||||
{HANDEDNESS_OPTIONS.map((option) => (
|
{HANDEDNESS_OPTIONS.map((option) => (
|
||||||
<option key={option} value={option}>
|
<MenuItem key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
</option>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</TextField>
|
||||||
</label>
|
<TextField
|
||||||
<label>
|
select
|
||||||
<span>Primary Position</span>
|
label="Primary Position"
|
||||||
<select value={value.primaryPosition} onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })}>
|
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>Secondary Position</span>
|
label="Secondary Position"
|
||||||
<select
|
|
||||||
value={value.secondaryPosition ?? ""}
|
value={value.secondaryPosition ?? ""}
|
||||||
onChange={(event) => 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>Tertiary Position</span>
|
label="Tertiary Position"
|
||||||
<select
|
|
||||||
value={value.tertiaryPosition ?? ""}
|
value={value.tertiaryPosition ?? ""}
|
||||||
onChange={(event) => 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="checkbox-row">
|
<FormControlLabel
|
||||||
<input type="checkbox" checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} />
|
control={<Checkbox checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} />}
|
||||||
<span>Active for selection</span>
|
label="Active for selection"
|
||||||
</label>
|
/>
|
||||||
<label className="full-width">
|
</Stack>
|
||||||
<span>Notes</span>
|
<TextField
|
||||||
<textarea rows={4} value={value.notes ?? ""} onChange={(event) => onChange({ ...value, notes: event.target.value })} />
|
label="Notes"
|
||||||
</label>
|
multiline
|
||||||
<div className="full-width">
|
rows={4}
|
||||||
<button className="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>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Alert, Box, Button, Card, CardContent, Divider, 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 { 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";
|
||||||
@@ -16,6 +18,7 @@ export function RosterClient() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [editingPlayerId, setEditingPlayerId] = 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 [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
||||||
const [appearanceForm, setAppearanceForm] = useState({
|
const [appearanceForm, setAppearanceForm] = useState({
|
||||||
playerId: "",
|
playerId: "",
|
||||||
@@ -72,13 +75,10 @@ export function RosterClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deletePlayer(player: PlayerRecord) {
|
async function deletePlayer(player: PlayerRecord) {
|
||||||
if (!window.confirm(`Remove ${formatPlayerName(player.firstName, player.lastName)} from the roster?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchJson(`/api/players/${player._id}`, { method: "DELETE" });
|
await fetchJson(`/api/players/${player._id}`, { method: "DELETE" });
|
||||||
setMessage("Player removed.");
|
setMessage("Player removed.");
|
||||||
|
setPlayerPendingDelete(null);
|
||||||
await loadPlayers();
|
await loadPlayers();
|
||||||
} catch (deleteError) {
|
} catch (deleteError) {
|
||||||
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete player.");
|
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete player.");
|
||||||
@@ -103,8 +103,21 @@ export function RosterClient() {
|
|||||||
title="Roster Management"
|
title="Roster Management"
|
||||||
subtitle="Add players, update availability, and keep pitching history current from one screen."
|
subtitle="Add players, update availability, and keep pitching history current from one screen."
|
||||||
>
|
>
|
||||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
<ConfirmDialog
|
||||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
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
|
<SectionCard
|
||||||
title={editingPlayerId ? "Edit Player" : "Add Player"}
|
title={editingPlayerId ? "Edit Player" : "Add Player"}
|
||||||
@@ -114,104 +127,124 @@ export function RosterClient() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Pitch Count Log" description="Record the latest outing to keep rest recommendations accurate.">
|
<SectionCard title="Pitch Count Log" description="Record the latest outing to keep rest recommendations accurate.">
|
||||||
<div className="form-grid">
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 2 }}>
|
||||||
<label>
|
<TextField
|
||||||
<span>Player</span>
|
select
|
||||||
<select
|
label="Player"
|
||||||
value={appearanceForm.playerId}
|
value={appearanceForm.playerId}
|
||||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))}
|
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>Game Date</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
type="date"
|
||||||
value={appearanceForm.gameDate}
|
value={appearanceForm.gameDate}
|
||||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))}
|
onChange={(event) => setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
/>
|
/>
|
||||||
</label>
|
<TextField
|
||||||
<label>
|
label="Pitches Thrown"
|
||||||
<span>Pitches Thrown</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
type="number"
|
||||||
value={appearanceForm.pitchCount}
|
value={appearanceForm.pitchCount}
|
||||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, pitchCount: Number(event.target.value) }))}
|
onChange={(event) => setAppearanceForm((current) => ({ ...current, pitchCount: Number(event.target.value) }))}
|
||||||
/>
|
/>
|
||||||
</label>
|
<TextField
|
||||||
<label className="full-width">
|
label="Notes"
|
||||||
<span>Notes</span>
|
multiline
|
||||||
<textarea
|
|
||||||
rows={3}
|
rows={3}
|
||||||
value={appearanceForm.notes}
|
value={appearanceForm.notes}
|
||||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, notes: event.target.value }))}
|
onChange={(event) => setAppearanceForm((current) => ({ ...current, notes: event.target.value }))}
|
||||||
|
sx={{ gridColumn: "1 / -1" }}
|
||||||
/>
|
/>
|
||||||
</label>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
<div className="full-width">
|
<Button variant="contained" onClick={() => void logAppearance()} disabled={players.length === 0}>
|
||||||
<button className="button" onClick={() => void logAppearance()} disabled={players.length === 0}>
|
|
||||||
Save Pitching Appearance
|
Save Pitching Appearance
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</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 ? (
|
||||||
<p className="muted-copy">Loading roster...</p>
|
<Typography color="text.secondary">Loading roster...</Typography>
|
||||||
) : players.length > 0 ? (
|
) : players.length > 0 ? (
|
||||||
<div className="card-grid">
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "repeat(2, 1fr)" }, gap: 2 }}>
|
||||||
{players.map((player) => (
|
{players.map((player) => (
|
||||||
<article key={player._id} className="player-card">
|
<Card key={player._id} elevation={0} sx={{ border: "1px solid rgba(15, 38, 47, 0.08)" }}>
|
||||||
<div className="player-card-head">
|
<CardContent sx={{ display: "grid", gap: 2 }}>
|
||||||
<div>
|
<Box
|
||||||
<h3>
|
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)}
|
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
|
||||||
</h3>
|
</Typography>
|
||||||
<p>
|
<Typography color="text.secondary">
|
||||||
{player.primaryPosition}
|
{player.primaryPosition}
|
||||||
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
|
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
|
||||||
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
|
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
<StatusBadge status={player.pitcherAvailability.status} />
|
<StatusBadge status={player.pitcherAvailability.status} />
|
||||||
</div>
|
</Box>
|
||||||
<dl className="player-meta">
|
|
||||||
<div>
|
<Divider />
|
||||||
<dt>Bats/Throws</dt>
|
|
||||||
<dd>{player.batsThrows}</dd>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" }, gap: 1.5 }}>
|
||||||
</div>
|
<Box>
|
||||||
<div>
|
<Typography variant="caption" color="text.secondary">
|
||||||
<dt>Status</dt>
|
Bats/Throws
|
||||||
<dd>{player.isActive ? "Active" : "Inactive"}</dd>
|
</Typography>
|
||||||
</div>
|
<Typography>{player.batsThrows}</Typography>
|
||||||
<div>
|
</Box>
|
||||||
<dt>Pitching</dt>
|
<Box>
|
||||||
<dd>
|
<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.lastPitchedDate
|
||||||
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
|
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
|
||||||
: "No history"}
|
: "No history"}
|
||||||
</dd>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</dl>
|
</Box>
|
||||||
{player.notes ? <p className="player-notes">{player.notes}</p> : null}
|
|
||||||
<div className="action-row">
|
{player.notes ? (
|
||||||
<button className="button button-secondary" onClick={() => setEditingPlayerId(player._id)}>
|
<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
|
Edit
|
||||||
</button>
|
</Button>
|
||||||
<button className="button button-danger" onClick={() => void deletePlayer(player)}>
|
<Button variant="contained" color="error" onClick={() => setPlayerPendingDelete(player)}>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
</article>
|
</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>
|
</SectionCard>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Card, CardContent, Stack, Typography } from "@mui/material";
|
||||||
|
|
||||||
export function SectionCard({
|
export function SectionCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -8,14 +10,25 @@ export function SectionCard({
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="section-card">
|
<Card
|
||||||
<div className="section-heading">
|
elevation={0}
|
||||||
<div>
|
sx={{
|
||||||
<h2>{title}</h2>
|
border: "1px solid rgba(15, 38, 47, 0.08)",
|
||||||
{description ? <p>{description}</p> : null}
|
backgroundColor: "rgba(255,255,255,0.8)",
|
||||||
</div>
|
backdropFilter: "blur(10px)"
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<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}
|
{children}
|
||||||
</section>
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"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";
|
||||||
@@ -50,24 +51,24 @@ 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 ? <div className="banner banner-success">{message}</div> : null}
|
{message ? <Alert severity="success">{message}</Alert> : null}
|
||||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
{error ? <Alert severity="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.">
|
||||||
<div className="table-wrap">
|
<Box sx={{ overflowX: "auto" }}>
|
||||||
<table className="rule-table">
|
<Table size="small">
|
||||||
<thead>
|
<TableHead>
|
||||||
<tr>
|
<TableRow>
|
||||||
<th>Min Pitches</th>
|
<TableCell>Min Pitches</TableCell>
|
||||||
<th>Max Pitches</th>
|
<TableCell>Max Pitches</TableCell>
|
||||||
<th>Rest Days</th>
|
<TableCell>Rest Days</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHead>
|
||||||
<tbody>
|
<TableBody>
|
||||||
{rules.map((rule, index) => (
|
{rules.map((rule, index) => (
|
||||||
<tr key={`${rule.minPitches}-${index}`}>
|
<TableRow key={`${rule.minPitches}-${index}`}>
|
||||||
<td>
|
<TableCell>
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={rule.minPitches}
|
value={rule.minPitches}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -78,9 +79,9 @@ export function SettingsClient() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</TableCell>
|
||||||
<td>
|
<TableCell>
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={rule.maxPitches ?? ""}
|
value={rule.maxPitches ?? ""}
|
||||||
placeholder="No max"
|
placeholder="No max"
|
||||||
@@ -94,9 +95,9 @@ export function SettingsClient() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</TableCell>
|
||||||
<td>
|
<TableCell>
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={rule.requiredRestDays}
|
value={rule.requiredRestDays}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -107,21 +108,21 @@ export function SettingsClient() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="action-row">
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.25}>
|
||||||
<button className="button button-secondary" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
|
<Button variant="outlined" color="inherit" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
|
||||||
Reset to Example Rules
|
Reset to Example Rules
|
||||||
</button>
|
</Button>
|
||||||
<button className="button" onClick={() => void saveRules()}>
|
<Button variant="contained" onClick={() => void saveRules()}>
|
||||||
Save Rules
|
Save Rules
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { Chip } from "@mui/material";
|
||||||
import type { PitcherAvailability } from "@/lib/types";
|
import type { PitcherAvailability } from "@/lib/types";
|
||||||
import { cn, statusLabel } from "@/lib/utils";
|
import { statusLabel } from "@/lib/utils";
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: PitcherAvailability }) {
|
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" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import { DEFAULT_PITCHING_RULES } from "@/lib/constants";
|
|
||||||
import { connectToDatabase } from "@/lib/db";
|
|
||||||
import { GameModel } from "@/models/Game";
|
|
||||||
import { LineupModel } from "@/models/Lineup";
|
|
||||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
|
||||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
|
||||||
import { PlayerModel } from "@/models/Player";
|
|
||||||
|
|
||||||
const samplePlayers = [
|
|
||||||
["Mason", "Reed", 2, "R/R", "SS", "P", "2B", true, "Steady glove and strong arm."],
|
|
||||||
["Eli", "Watson", 4, "R/R", "P", "1B", "CF", true, "Reliable control pitcher."],
|
|
||||||
["Noah", "Foster", 7, "L/L", "CF", "LF", "P", true, "Tracks fly balls well."],
|
|
||||||
["Liam", "Parker", 9, "R/R", "C", "3B", "1B", true, "Best communicator on defense."],
|
|
||||||
["Owen", "Hayes", 11, "R/L", "2B", "SS", "CF", true, "Quick hands on double plays."],
|
|
||||||
["Lucas", "Brooks", 14, "L/R", "1B", "P", "RF", true, "Lefty target at first base."],
|
|
||||||
["Jack", "Morris", 18, "R/R", "3B", "C", "2B", true, "Aggressive base runner."],
|
|
||||||
["Henry", "Cole", 21, "Switch", "LF", "RF", "CF", true, "Good contact hitter."],
|
|
||||||
["Wyatt", "Price", 24, "R/R", "RF", "LF", "1B", true, "Can fill corner spots."],
|
|
||||||
["Caleb", "Ross", 27, "R/R", "P", "UTIL", "1B", false, "Recovering from a sore shoulder."]
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export async function seedDatabase() {
|
|
||||||
await connectToDatabase();
|
|
||||||
|
|
||||||
const playerCount = await PlayerModel.countDocuments();
|
|
||||||
if (playerCount > 0) {
|
|
||||||
return { seeded: false, reason: "Players already exist." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdPlayers = await PlayerModel.insertMany(
|
|
||||||
samplePlayers.map(([firstName, lastName, jerseyNumber, batsThrows, primaryPosition, secondaryPosition, tertiaryPosition, isActive, notes]) => ({
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
jerseyNumber,
|
|
||||||
batsThrows,
|
|
||||||
primaryPosition,
|
|
||||||
secondaryPosition,
|
|
||||||
tertiaryPosition,
|
|
||||||
isActive,
|
|
||||||
notes
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
await PitchingRuleModel.insertMany(
|
|
||||||
DEFAULT_PITCHING_RULES.map((rule, index) => ({
|
|
||||||
...rule,
|
|
||||||
sortOrder: index
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const lookup = Object.fromEntries(createdPlayers.map((player) => [`${player.firstName} ${player.lastName}`, player._id]));
|
|
||||||
|
|
||||||
await PitchingAppearanceModel.insertMany([
|
|
||||||
{ playerId: lookup["Eli Watson"], gameDate: new Date("2026-05-03"), pitchCount: 42, notes: "Saturday league game." },
|
|
||||||
{ playerId: lookup["Mason Reed"], gameDate: new Date("2026-05-05"), pitchCount: 18, notes: "Short relief outing." },
|
|
||||||
{ playerId: lookup["Noah Foster"], gameDate: new Date("2026-04-28"), pitchCount: 55, notes: "Closed a tough game." },
|
|
||||||
{ playerId: lookup["Lucas Brooks"], gameDate: new Date("2026-05-01"), pitchCount: 24, notes: "Spot start." }
|
|
||||||
]);
|
|
||||||
|
|
||||||
await GameModel.create({
|
|
||||||
gameDate: new Date("2026-05-07"),
|
|
||||||
activePlayerIds: createdPlayers.filter((player) => player.isActive).map((player) => player._id),
|
|
||||||
startingPitcherId: lookup["Eli Watson"],
|
|
||||||
notes: "Sample upcoming game."
|
|
||||||
});
|
|
||||||
|
|
||||||
await LineupModel.create({
|
|
||||||
gameDate: new Date("2026-05-07"),
|
|
||||||
playerIds: createdPlayers.filter((player) => player.isActive).map((player) => player._id),
|
|
||||||
startingPitcherId: lookup["Eli Watson"],
|
|
||||||
battingOrder: createdPlayers
|
|
||||||
.filter((player) => player.isActive)
|
|
||||||
.slice(0, 9)
|
|
||||||
.map((player, index) => ({
|
|
||||||
playerId: player._id,
|
|
||||||
playerName: `${player.firstName} ${player.lastName}`,
|
|
||||||
slot: index + 1
|
|
||||||
})),
|
|
||||||
defensiveAssignments: [
|
|
||||||
["Eli Watson", "P"],
|
|
||||||
["Liam Parker", "C"],
|
|
||||||
["Lucas Brooks", "1B"],
|
|
||||||
["Owen Hayes", "2B"],
|
|
||||||
["Jack Morris", "3B"],
|
|
||||||
["Mason Reed", "SS"],
|
|
||||||
["Henry Cole", "LF"],
|
|
||||||
["Noah Foster", "CF"],
|
|
||||||
["Wyatt Price", "RF"]
|
|
||||||
].map(([name, position]) => ({
|
|
||||||
playerId: lookup[name],
|
|
||||||
playerName: name,
|
|
||||||
position
|
|
||||||
})),
|
|
||||||
bench: [],
|
|
||||||
notes: ["Sample lineup generated for reference."]
|
|
||||||
});
|
|
||||||
|
|
||||||
return { seeded: true };
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { seedDatabase } from "@/lib/seed";
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const result = await seedDatabase();
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(result);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((error) => {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user