Updating
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/roster", label: "Roster" },
|
||||
{ href: "/lineup", label: "Generate Lineup" },
|
||||
{ href: "/settings", label: "Pitching Rules" }
|
||||
];
|
||||
|
||||
export function AppShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Little League Coach Console</p>
|
||||
<h1>{title}</h1>
|
||||
<p className="hero-copy">{subtitle}</p>
|
||||
</div>
|
||||
{actions ? <div className="hero-actions">{actions}</div> : null}
|
||||
</header>
|
||||
|
||||
<nav className="tabs" aria-label="Primary">
|
||||
{navItems.map((item) => (
|
||||
<Link key={item.href} href={item.href as Route} className={cn("tab", pathname === item.href && "tab-active")}>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className="page-grid">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import type { DashboardResponse } from "@/lib/types";
|
||||
|
||||
export function DashboardClient() {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
async function loadDashboard() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchJson<DashboardResponse>("/api/dashboard");
|
||||
setData(response);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load dashboard.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedSampleData() {
|
||||
try {
|
||||
const response = await fetchJson<{ seeded: boolean; reason?: string }>("/api/seed", { method: "POST" });
|
||||
setMessage(response.seeded ? "Sample team loaded." : response.reason ?? "Seed skipped.");
|
||||
await loadDashboard();
|
||||
} catch (seedError) {
|
||||
setError(seedError instanceof Error ? seedError.message : "Failed to seed data.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Game Day Snapshot"
|
||||
subtitle="Check roster health, pitcher rest, and lineup readiness before first pitch."
|
||||
actions={
|
||||
<button className="button button-secondary" onClick={() => void seedSampleData()}>
|
||||
Load Sample Team
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<SectionCard title="Loading" description="Pulling current roster and pitching data." />
|
||||
) : data && data.rosterCount > 0 ? (
|
||||
<>
|
||||
<section className="stats-grid">
|
||||
<article className="stat-card">
|
||||
<span>Rostered Players</span>
|
||||
<strong>{data.rosterCount}</strong>
|
||||
</article>
|
||||
<article className="stat-card">
|
||||
<span>Active Today</span>
|
||||
<strong>{data.activePlayers}</strong>
|
||||
</article>
|
||||
<article className="stat-card stat-card-success">
|
||||
<span>Pitchers Available</span>
|
||||
<strong>{data.availablePitchers}</strong>
|
||||
</article>
|
||||
<article className="stat-card stat-card-warning">
|
||||
<span>Need Rest</span>
|
||||
<strong>{data.needsRest}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<SectionCard
|
||||
title="Pitcher Availability"
|
||||
description="Availability is based on the latest appearance and your configured rest rules."
|
||||
>
|
||||
<div className="list-stack">
|
||||
{data.recentPitching.map((entry) => (
|
||||
<article key={entry.playerId} className="list-row">
|
||||
<div>
|
||||
<h3>{entry.playerName}</h3>
|
||||
<p>
|
||||
{entry.lastPitchedDate
|
||||
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
|
||||
: "No pitching history recorded yet."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="list-meta">
|
||||
<StatusBadge status={entry.status} />
|
||||
<small>
|
||||
{entry.daysSinceLastAppearance === null
|
||||
? "No history"
|
||||
: `${entry.daysSinceLastAppearance} day(s) since last outing`}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
) : (
|
||||
<SectionCard title="No Team Yet" description="Start with sample data or add players on the roster screen.">
|
||||
<EmptyState
|
||||
title="Roster is empty"
|
||||
description="Load the sample team to explore the app, then adjust players and pitching records as needed."
|
||||
action={
|
||||
<button className="button" onClick={() => void seedSampleData()}>
|
||||
Load Sample Team
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { FIELD_POSITIONS } from "@/lib/constants";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
import type { GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
|
||||
|
||||
export function LineupClient() {
|
||||
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
||||
const [selectedPlayerIds, setSelectedPlayerIds] = useState<string[]>([]);
|
||||
const [startingPitcherId, setStartingPitcherId] = useState<string>("");
|
||||
const [showAllPitchers, setShowAllPitchers] = useState(false);
|
||||
const [generated, setGenerated] = useState<GeneratedLineup | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadPlayers(targetDate: string) {
|
||||
try {
|
||||
const response = await fetchJson<PlayerRecord[]>(`/api/players?gameDate=${targetDate}`);
|
||||
setPlayers(response);
|
||||
const activeIds = response.filter((player) => player.isActive).map((player) => player._id);
|
||||
setSelectedPlayerIds(activeIds);
|
||||
const firstEligible = response.find((player) => player.pitcherAvailability.status === "available");
|
||||
setStartingPitcherId(firstEligible?._id ?? response[0]?._id ?? "");
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load players.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlayers(gameDate);
|
||||
}, [gameDate]);
|
||||
|
||||
const selectablePitchers = useMemo(
|
||||
() =>
|
||||
players.filter((player) =>
|
||||
selectedPlayerIds.includes(player._id) &&
|
||||
(showAllPitchers || player.pitcherAvailability.status !== "needs-rest")
|
||||
),
|
||||
[players, selectedPlayerIds, showAllPitchers]
|
||||
);
|
||||
|
||||
const selectedPlayers = useMemo(
|
||||
() => players.filter((player) => selectedPlayerIds.includes(player._id)),
|
||||
[players, selectedPlayerIds]
|
||||
);
|
||||
|
||||
function updateGenerated(next: GeneratedLineup | null) {
|
||||
setGenerated(next);
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
try {
|
||||
const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
gameDate,
|
||||
playerIds: selectedPlayerIds,
|
||||
startingPitcherId: startingPitcherId || null
|
||||
})
|
||||
});
|
||||
setGenerated(response);
|
||||
setMessage("Suggested lineup generated.");
|
||||
} catch (generateError) {
|
||||
setError(generateError instanceof Error ? generateError.message : "Unable to generate lineup.");
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!generated) return;
|
||||
|
||||
try {
|
||||
await fetchJson<LineupRecord>("/api/lineups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...generated,
|
||||
playerIds: selectedPlayerIds
|
||||
})
|
||||
});
|
||||
setMessage("Lineup saved.");
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to save lineup.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Generate Lineup"
|
||||
subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed."
|
||||
>
|
||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
||||
|
||||
<SectionCard title="Game Setup" description="Start with the players who are available today.">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span>Game Date</span>
|
||||
<input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Starting Pitcher</span>
|
||||
<select value={startingPitcherId} onChange={(event) => setStartingPitcherId(event.target.value)}>
|
||||
{selectablePitchers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={showAllPitchers} onChange={(event) => setShowAllPitchers(event.target.checked)} />
|
||||
<span>Show pitchers who need rest</span>
|
||||
</label>
|
||||
<div className="full-width action-row">
|
||||
<button className="button" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
||||
Generate Suggested Lineup
|
||||
</button>
|
||||
<button className="button button-secondary" disabled={!generated} onClick={() => void save()}>
|
||||
Save Lineup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="picker-grid">
|
||||
{players.map((player) => (
|
||||
<label key={player._id} className="picker-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPlayerIds.includes(player._id)}
|
||||
onChange={(event) =>
|
||||
setSelectedPlayerIds((current) =>
|
||||
event.target.checked ? [...current, player._id] : current.filter((id) => id !== player._id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<strong>{formatPlayerName(player.firstName, player.lastName)}</strong>
|
||||
<p>
|
||||
#{player.jerseyNumber} {player.primaryPosition}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={player.pitcherAvailability.status} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{generated ? (
|
||||
<>
|
||||
<SectionCard title="Batting Order" description="Adjust any slot manually before saving.">
|
||||
<div className="list-stack">
|
||||
{generated.battingOrder.map((slot, index) => (
|
||||
<div key={`${slot.playerId}-${index}`} className="list-row">
|
||||
<div>
|
||||
<h3>Spot {index + 1}</h3>
|
||||
</div>
|
||||
<select
|
||||
value={slot.playerId}
|
||||
onChange={(event) => {
|
||||
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
||||
if (!nextPlayer) return;
|
||||
updateGenerated({
|
||||
...generated,
|
||||
battingOrder: generated.battingOrder.map((entry, entryIndex) =>
|
||||
entryIndex === index
|
||||
? {
|
||||
...entry,
|
||||
playerId: nextPlayer._id,
|
||||
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName)
|
||||
}
|
||||
: entry
|
||||
)
|
||||
});
|
||||
}}
|
||||
>
|
||||
{selectedPlayers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Defensive Alignment" description="Assignments follow position preferences, but every field is editable.">
|
||||
<div className="list-stack">
|
||||
{FIELD_POSITIONS.map((position) => {
|
||||
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
||||
return (
|
||||
<div key={position} className="list-row">
|
||||
<div>
|
||||
<h3>{position}</h3>
|
||||
</div>
|
||||
<select
|
||||
value={assignment?.playerId ?? ""}
|
||||
onChange={(event) => {
|
||||
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
||||
if (!nextPlayer) return;
|
||||
const nextAssignments = generated.defensiveAssignments.filter((entry) => entry.position !== position);
|
||||
nextAssignments.push({
|
||||
playerId: nextPlayer._id,
|
||||
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
|
||||
position
|
||||
});
|
||||
updateGenerated({
|
||||
...generated,
|
||||
defensiveAssignments: nextAssignments
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{selectedPlayers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{generated.notes.length > 0 ? (
|
||||
<div className="note-block">
|
||||
{generated.notes.map((note) => (
|
||||
<p key={note}>{note}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
</>
|
||||
) : (
|
||||
<SectionCard title="No Lineup Yet" description="Generate a lineup once your game-day roster is selected.">
|
||||
<EmptyState
|
||||
title="Waiting on lineup"
|
||||
description="Select your active players, choose a starter, and generate a suggested batting order and field setup."
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
|
||||
import type { PlayerInput, PlayerRecord } from "@/lib/types";
|
||||
|
||||
const defaultForm: PlayerInput = {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
jerseyNumber: 0,
|
||||
batsThrows: "R/R",
|
||||
primaryPosition: "P",
|
||||
secondaryPosition: "C",
|
||||
tertiaryPosition: "1B",
|
||||
isActive: true,
|
||||
notes: ""
|
||||
};
|
||||
|
||||
export function emptyPlayerForm() {
|
||||
return { ...defaultForm };
|
||||
}
|
||||
|
||||
export function toPlayerForm(player: PlayerRecord): PlayerInput {
|
||||
return {
|
||||
firstName: player.firstName,
|
||||
lastName: player.lastName,
|
||||
jerseyNumber: player.jerseyNumber,
|
||||
batsThrows: player.batsThrows,
|
||||
primaryPosition: player.primaryPosition,
|
||||
secondaryPosition: player.secondaryPosition ?? null,
|
||||
tertiaryPosition: player.tertiaryPosition ?? null,
|
||||
isActive: player.isActive,
|
||||
notes: player.notes ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
export function PlayerForm({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
submitLabel
|
||||
}: {
|
||||
value: PlayerInput;
|
||||
onChange: (next: PlayerInput) => void;
|
||||
onSubmit: () => void;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span>First Name</span>
|
||||
<input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Last Name</span>
|
||||
<input value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Jersey #</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value.jerseyNumber}
|
||||
onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Bats / Throws</span>
|
||||
<select value={value.batsThrows} onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}>
|
||||
{HANDEDNESS_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Primary Position</span>
|
||||
<select value={value.primaryPosition} onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })}>
|
||||
{POSITIONS.map((position) => (
|
||||
<option key={position} value={position}>
|
||||
{position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Secondary Position</span>
|
||||
<select
|
||||
value={value.secondaryPosition ?? ""}
|
||||
onChange={(event) => onChange({ ...value, secondaryPosition: event.target.value ? (event.target.value as PlayerInput["secondaryPosition"]) : null })}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{POSITIONS.map((position) => (
|
||||
<option key={position} value={position}>
|
||||
{position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tertiary Position</span>
|
||||
<select
|
||||
value={value.tertiaryPosition ?? ""}
|
||||
onChange={(event) => onChange({ ...value, tertiaryPosition: event.target.value ? (event.target.value as PlayerInput["tertiaryPosition"]) : null })}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{POSITIONS.map((position) => (
|
||||
<option key={position} value={position}>
|
||||
{position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} />
|
||||
<span>Active for selection</span>
|
||||
</label>
|
||||
<label className="full-width">
|
||||
<span>Notes</span>
|
||||
<textarea rows={4} value={value.notes ?? ""} onChange={(event) => onChange({ ...value, notes: event.target.value })} />
|
||||
</label>
|
||||
<div className="full-width">
|
||||
<button className="button" onClick={onSubmit}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { PlayerForm, emptyPlayerForm, toPlayerForm } from "@/components/PlayerForm";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
import type { PitchingAppearanceRecord, PlayerInput, PlayerRecord } from "@/lib/types";
|
||||
|
||||
export function RosterClient() {
|
||||
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null);
|
||||
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
||||
const [appearanceForm, setAppearanceForm] = useState({
|
||||
playerId: "",
|
||||
gameDate: new Date().toISOString().slice(0, 10),
|
||||
pitchCount: 20,
|
||||
notes: ""
|
||||
});
|
||||
|
||||
const editingPlayer = useMemo(
|
||||
() => players.find((player) => player._id === editingPlayerId) ?? null,
|
||||
[editingPlayerId, players]
|
||||
);
|
||||
|
||||
async function loadPlayers() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetchJson<PlayerRecord[]>("/api/players");
|
||||
setPlayers(response);
|
||||
if (response.length > 0 && !appearanceForm.playerId) {
|
||||
setAppearanceForm((current) => ({ ...current, playerId: response[0]._id }));
|
||||
}
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load roster.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlayers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingPlayer) {
|
||||
setPlayerForm(toPlayerForm(editingPlayer));
|
||||
} else {
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
}
|
||||
}, [editingPlayer]);
|
||||
|
||||
async function submitPlayer() {
|
||||
try {
|
||||
const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players";
|
||||
const method = editingPlayerId ? "PATCH" : "POST";
|
||||
await fetchJson<PlayerRecord>(url, { method, body: JSON.stringify(playerForm) });
|
||||
setMessage(editingPlayerId ? "Player updated." : "Player added.");
|
||||
setEditingPlayerId(null);
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
await loadPlayers();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "Unable to save player.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlayer(player: PlayerRecord) {
|
||||
if (!window.confirm(`Remove ${formatPlayerName(player.firstName, player.lastName)} from the roster?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchJson(`/api/players/${player._id}`, { method: "DELETE" });
|
||||
setMessage("Player removed.");
|
||||
await loadPlayers();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete player.");
|
||||
}
|
||||
}
|
||||
|
||||
async function logAppearance() {
|
||||
try {
|
||||
await fetchJson<PitchingAppearanceRecord>("/api/pitching-appearances", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(appearanceForm)
|
||||
});
|
||||
setMessage("Pitching appearance recorded.");
|
||||
await loadPlayers();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "Unable to save pitching appearance.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Roster Management"
|
||||
subtitle="Add players, update availability, and keep pitching history current from one screen."
|
||||
>
|
||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
||||
|
||||
<SectionCard
|
||||
title={editingPlayerId ? "Edit Player" : "Add Player"}
|
||||
description="Keep player details simple so a coach can update them quickly before game time."
|
||||
>
|
||||
<PlayerForm value={playerForm} onChange={setPlayerForm} onSubmit={() => void submitPlayer()} submitLabel={editingPlayerId ? "Save Changes" : "Add Player"} />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Pitch Count Log" description="Record the latest outing to keep rest recommendations accurate.">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span>Player</span>
|
||||
<select
|
||||
value={appearanceForm.playerId}
|
||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))}
|
||||
>
|
||||
{players.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Game Date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={appearanceForm.gameDate}
|
||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Pitches Thrown</span>
|
||||
<input
|
||||
type="number"
|
||||
value={appearanceForm.pitchCount}
|
||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, pitchCount: Number(event.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="full-width">
|
||||
<span>Notes</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={appearanceForm.notes}
|
||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, notes: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="full-width">
|
||||
<button className="button" onClick={() => void logAppearance()} disabled={players.length === 0}>
|
||||
Save Pitching Appearance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Current Roster" description="Tap edit for fast changes and check pitcher status at a glance.">
|
||||
{loading ? (
|
||||
<p className="muted-copy">Loading roster...</p>
|
||||
) : players.length > 0 ? (
|
||||
<div className="card-grid">
|
||||
{players.map((player) => (
|
||||
<article key={player._id} className="player-card">
|
||||
<div className="player-card-head">
|
||||
<div>
|
||||
<h3>
|
||||
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
|
||||
</h3>
|
||||
<p>
|
||||
{player.primaryPosition}
|
||||
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
|
||||
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={player.pitcherAvailability.status} />
|
||||
</div>
|
||||
<dl className="player-meta">
|
||||
<div>
|
||||
<dt>Bats/Throws</dt>
|
||||
<dd>{player.batsThrows}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{player.isActive ? "Active" : "Inactive"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Pitching</dt>
|
||||
<dd>
|
||||
{player.pitcherAvailability.lastPitchedDate
|
||||
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
|
||||
: "No history"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{player.notes ? <p className="player-notes">{player.notes}</p> : null}
|
||||
<div className="action-row">
|
||||
<button className="button button-secondary" onClick={() => setEditingPlayerId(player._id)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="button button-danger" onClick={() => void deletePlayer(player)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="No players yet" description="Add your first player above or load sample data from the dashboard." />
|
||||
)}
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="section-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { DEFAULT_PITCHING_RULES } from "@/lib/constants";
|
||||
import type { PitchingRuleRecord } from "@/lib/types";
|
||||
|
||||
export function SettingsClient() {
|
||||
const [rules, setRules] = useState<PitchingRuleRecord[]>(DEFAULT_PITCHING_RULES);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
async function loadRules() {
|
||||
try {
|
||||
const response = await fetchJson<PitchingRuleRecord[]>("/api/pitching-rules");
|
||||
if (response.length > 0) {
|
||||
setRules(
|
||||
response.map((rule) => ({
|
||||
minPitches: rule.minPitches,
|
||||
maxPitches: rule.maxPitches,
|
||||
requiredRestDays: rule.requiredRestDays
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load rules.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRules();
|
||||
}, []);
|
||||
|
||||
async function saveRules() {
|
||||
try {
|
||||
await fetchJson("/api/pitching-rules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(rules)
|
||||
});
|
||||
setMessage("Pitching rules updated.");
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Failed to save rules.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Pitching Rest Rules"
|
||||
subtitle="Tune the rest windows your league uses so eligibility stays accurate and easy to trust."
|
||||
>
|
||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
||||
|
||||
<SectionCard title="Rule Table" description="Each row maps a pitch-count range to required rest days.">
|
||||
<div className="table-wrap">
|
||||
<table className="rule-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Min Pitches</th>
|
||||
<th>Max Pitches</th>
|
||||
<th>Rest Days</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule, index) => (
|
||||
<tr key={`${rule.minPitches}-${index}`}>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.minPitches}
|
||||
onChange={(event) =>
|
||||
setRules((current) =>
|
||||
current.map((entry, entryIndex) =>
|
||||
entryIndex === index ? { ...entry, minPitches: Number(event.target.value) } : entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.maxPitches ?? ""}
|
||||
placeholder="No max"
|
||||
onChange={(event) =>
|
||||
setRules((current) =>
|
||||
current.map((entry, entryIndex) =>
|
||||
entryIndex === index
|
||||
? { ...entry, maxPitches: event.target.value ? Number(event.target.value) : null }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.requiredRestDays}
|
||||
onChange={(event) =>
|
||||
setRules((current) =>
|
||||
current.map((entry, entryIndex) =>
|
||||
entryIndex === index ? { ...entry, requiredRestDays: Number(event.target.value) } : entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="action-row">
|
||||
<button className="button button-secondary" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
|
||||
Reset to Example Rules
|
||||
</button>
|
||||
<button className="button" onClick={() => void saveRules()}>
|
||||
Save Rules
|
||||
</button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { PitcherAvailability } from "@/lib/types";
|
||||
import { cn, statusLabel } from "@/lib/utils";
|
||||
|
||||
export function StatusBadge({ status }: { status: PitcherAvailability }) {
|
||||
return <span className={cn("badge", `badge-${status}`)}>{statusLabel(status)}</span>;
|
||||
}
|
||||
Reference in New Issue
Block a user