Updating
This commit is contained in:
Generated
+56
@@ -8,6 +8,9 @@
|
|||||||
"name": "little-league-roster-manager",
|
"name": "little-league-roster-manager",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -42,6 +45,59 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { handleApiError } from "@/app/api/_utils";
|
||||||
|
import { connectToDatabase } from "@/lib/db";
|
||||||
|
import { LineupModel } from "@/models/Lineup";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
await connectToDatabase();
|
||||||
|
const { id } = await params;
|
||||||
|
const lineup = await LineupModel.findByIdAndDelete(id).lean();
|
||||||
|
|
||||||
|
if (!lineup) {
|
||||||
|
return NextResponse.json({ error: "Lineup not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const gameDate = searchParams.get("gameDate");
|
const gameDate = searchParams.get("gameDate");
|
||||||
const filter = gameDate ? { gameDate: new Date(gameDate) } : {};
|
const filter = gameDate ? { gameDate: new Date(gameDate) } : {};
|
||||||
const lineups = await LineupModel.find(filter).sort({ gameDate: -1 }).lean();
|
const lineups = await LineupModel.find(filter).sort({ gameDate: -1, lineupNumber: -1 }).lean();
|
||||||
return NextResponse.json(serialize(lineups));
|
return NextResponse.json(serialize(lineups));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
@@ -26,8 +26,12 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
await connectToDatabase();
|
await connectToDatabase();
|
||||||
const payload = saveLineupSchema.parse(await request.json());
|
const payload = saveLineupSchema.parse(await request.json());
|
||||||
|
const gameDate = new Date(payload.gameDate);
|
||||||
|
const latestForDate = await LineupModel.findOne({ gameDate }).sort({ lineupNumber: -1 }).select("lineupNumber").lean();
|
||||||
|
const lineupNumber = (latestForDate?.lineupNumber ?? 0) + 1;
|
||||||
const lineupPayload = {
|
const lineupPayload = {
|
||||||
gameDate: new Date(payload.gameDate),
|
gameDate,
|
||||||
|
lineupNumber,
|
||||||
playerIds: payload.playerIds,
|
playerIds: payload.playerIds,
|
||||||
startingPitcherId: payload.startingPitcherId ?? null,
|
startingPitcherId: payload.startingPitcherId ?? null,
|
||||||
battingOrder: payload.battingOrder,
|
battingOrder: payload.battingOrder,
|
||||||
@@ -36,11 +40,7 @@ export async function POST(request: Request) {
|
|||||||
notes: payload.notes
|
notes: payload.notes
|
||||||
};
|
};
|
||||||
|
|
||||||
const lineup = await LineupModel.findOneAndUpdate({ gameDate: lineupPayload.gameDate }, lineupPayload, {
|
const lineup = await LineupModel.create(lineupPayload);
|
||||||
upsert: true,
|
|
||||||
new: true,
|
|
||||||
runValidators: true
|
|
||||||
}).lean();
|
|
||||||
|
|
||||||
await GameModel.findOneAndUpdate(
|
await GameModel.findOneAndUpdate(
|
||||||
{ gameDate: lineupPayload.gameDate },
|
{ gameDate: lineupPayload.gameDate },
|
||||||
@@ -52,7 +52,7 @@ export async function POST(request: Request) {
|
|||||||
{ upsert: true, new: true }
|
{ upsert: true, new: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json(serialize(lineup), { status: 201 });
|
return NextResponse.json(serialize(lineup.toObject()), { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PitchTrackingClient } from "@/components/PitchTrackingClient";
|
||||||
|
|
||||||
|
export default function PitchTrackingPage() {
|
||||||
|
return <PitchTrackingClient />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BarChart3, ClipboardList, ShieldCheck, Trophy, Users } from "lucide-react";
|
import { BarChart3, ClipboardList, ShieldCheck, TimerReset, Trophy, Users } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 },
|
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 },
|
||||||
{ href: "/roster", label: "Roster", icon: Users },
|
{ href: "/roster", label: "Roster", icon: Users },
|
||||||
|
{ href: "/pitching", label: "Pitch Tracking", icon: TimerReset },
|
||||||
{ href: "/lineup", label: "Generate Lineup", icon: ClipboardList },
|
{ href: "/lineup", label: "Generate Lineup", icon: ClipboardList },
|
||||||
{ href: "/settings", label: "Pitching Rules", icon: ShieldCheck }
|
{ href: "/settings", label: "Pitching Rules", icon: ShieldCheck }
|
||||||
];
|
];
|
||||||
@@ -26,24 +27,24 @@ export function AppShell({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-4 md:py-8">
|
<div className="container py-3 md:py-6">
|
||||||
<header className="relative overflow-hidden rounded-[2rem] border bg-primary p-6 text-primary-foreground shadow-glow md:p-10">
|
<header className="relative overflow-hidden rounded-[1.75rem] border bg-primary p-4 text-primary-foreground shadow-glow md:p-7">
|
||||||
<div className="pointer-events-none absolute inset-0 bg-hero-grid bg-[size:38px_38px] opacity-[0.08]" />
|
<div className="pointer-events-none absolute inset-0 bg-hero-grid bg-[size:38px_38px] opacity-[0.08]" />
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-white/10 to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-white/10 to-transparent" />
|
||||||
<div className="relative flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-white/80">
|
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-white/80">
|
||||||
<Trophy className="h-4 w-4" />
|
<Trophy className="h-4 w-4" />
|
||||||
Little League Coach Console
|
Little League Coach Console
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display text-4xl font-semibold leading-none tracking-tight md:text-6xl">{title}</h1>
|
<h1 className="font-display text-3xl font-semibold leading-none tracking-tight md:text-5xl">{title}</h1>
|
||||||
<p className="mt-4 max-w-2xl text-base text-white/70 md:text-lg">{subtitle}</p>
|
<p className="mt-3 max-w-2xl text-sm text-white/70 md:text-base">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="relative flex items-start">{actions}</div> : null}
|
{actions ? <div className="relative flex items-start">{actions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="app-surface mt-4 flex flex-wrap gap-2 p-2">
|
<nav className="app-surface mt-3 flex flex-wrap gap-1.5 p-1.5">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = pathname === item.href;
|
const active = pathname === item.href;
|
||||||
@@ -52,7 +53,7 @@ export function AppShell({
|
|||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition",
|
"inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-medium transition md:text-sm",
|
||||||
active
|
active
|
||||||
? "bg-primary text-primary-foreground shadow-panel"
|
? "bg-primary text-primary-foreground shadow-panel"
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
@@ -65,7 +66,7 @@ export function AppShell({
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="mt-5 grid gap-5">{children}</main>
|
<main className="mt-4 grid gap-4">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { GripVertical, Trash2 } from "lucide-react";
|
||||||
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 { SectionCard } from "@/components/SectionCard";
|
import { SectionCard } from "@/components/SectionCard";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
@@ -12,7 +32,68 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { fetchJson } from "@/lib/client";
|
import { fetchJson } from "@/lib/client";
|
||||||
import { FIELD_POSITIONS } from "@/lib/constants";
|
import { FIELD_POSITIONS } from "@/lib/constants";
|
||||||
import { formatPlayerName } from "@/lib/utils";
|
import { formatPlayerName } from "@/lib/utils";
|
||||||
import type { GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
|
import type { BattingSlot, GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
function SortableBattingOrderItem({
|
||||||
|
slot,
|
||||||
|
index,
|
||||||
|
selectedPlayers,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
slot: BattingSlot;
|
||||||
|
index: number;
|
||||||
|
selectedPlayers: PlayerRecord[];
|
||||||
|
onChange: (playerId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||||
|
id: slot.playerId
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="grid gap-3 rounded-[1.5rem] border border-border bg-white/70 p-3 md:grid-cols-[auto_72px_1fr] md:items-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hidden rounded-full p-2 text-muted-foreground hover:bg-muted md:inline-flex"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label={`Drag batting spot ${index + 1}`}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center justify-between md:justify-start">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground md:hidden">
|
||||||
|
Drag
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex rounded-full p-2 text-muted-foreground hover:bg-muted md:hidden"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label={`Drag batting spot ${index + 1}`}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<p className="text-lg font-semibold">#{index + 1}</p>
|
||||||
|
</div>
|
||||||
|
<select className="field-select md:col-start-3" value={slot.playerId} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
{selectedPlayers.map((player) => (
|
||||||
|
<option key={player._id} value={player._id}>
|
||||||
|
{formatPlayerName(player.firstName, player.lastName)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LineupClient() {
|
export function LineupClient() {
|
||||||
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
|
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
|
||||||
@@ -21,9 +102,19 @@ export function LineupClient() {
|
|||||||
const [startingPitcherId, setStartingPitcherId] = useState<string>("");
|
const [startingPitcherId, setStartingPitcherId] = useState<string>("");
|
||||||
const [showAllPitchers, setShowAllPitchers] = useState(false);
|
const [showAllPitchers, setShowAllPitchers] = useState(false);
|
||||||
const [generated, setGenerated] = useState<GeneratedLineup | null>(null);
|
const [generated, setGenerated] = useState<GeneratedLineup | null>(null);
|
||||||
|
const [savedLineups, setSavedLineups] = useState<LineupRecord[]>([]);
|
||||||
|
const [lineupPendingDelete, setLineupPendingDelete] = useState<LineupRecord | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(TouchSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
async function loadPlayers(targetDate: string) {
|
async function loadPlayers(targetDate: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetchJson<PlayerRecord[]>(`/api/players?gameDate=${targetDate}`);
|
const response = await fetchJson<PlayerRecord[]>(`/api/players?gameDate=${targetDate}`);
|
||||||
@@ -37,8 +128,18 @@ export function LineupClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSavedLineups(targetDate: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetchJson<LineupRecord[]>(`/api/lineups?gameDate=${targetDate}`);
|
||||||
|
setSavedLineups(response);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(loadError instanceof Error ? loadError.message : "Failed to load saved lineups.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadPlayers(gameDate);
|
void loadPlayers(gameDate);
|
||||||
|
void loadSavedLineups(gameDate);
|
||||||
}, [gameDate]);
|
}, [gameDate]);
|
||||||
|
|
||||||
const selectablePitchers = useMemo(
|
const selectablePitchers = useMemo(
|
||||||
@@ -60,6 +161,34 @@ export function LineupClient() {
|
|||||||
setGenerated(next);
|
setGenerated(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateBattingOrder(order: BattingSlot[]) {
|
||||||
|
if (!generated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGenerated({
|
||||||
|
...generated,
|
||||||
|
battingOrder: order.map((entry, index) => ({ ...entry, slot: index + 1 }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBattingDragEnd(event: DragEndEvent) {
|
||||||
|
if (!generated || !event.over || event.active.id === event.over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overId = event.over.id;
|
||||||
|
|
||||||
|
const oldIndex = generated.battingOrder.findIndex((slot) => slot.playerId === event.active.id);
|
||||||
|
const newIndex = generated.battingOrder.findIndex((slot) => slot.playerId === overId);
|
||||||
|
|
||||||
|
if (oldIndex < 0 || newIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBattingOrder(arrayMove(generated.battingOrder, oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
try {
|
try {
|
||||||
const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", {
|
const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", {
|
||||||
@@ -81,29 +210,60 @@ export function LineupClient() {
|
|||||||
if (!generated) return;
|
if (!generated) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchJson<LineupRecord>("/api/lineups", {
|
const saved = await fetchJson<LineupRecord>("/api/lineups", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...generated,
|
...generated,
|
||||||
playerIds: selectedPlayerIds
|
playerIds: selectedPlayerIds
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
setMessage("Lineup saved.");
|
setMessage(`Saved as lineup #${saved.lineupNumber} for ${saved.gameDate}.`);
|
||||||
|
await loadSavedLineups(gameDate);
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
setError(saveError instanceof Error ? saveError.message : "Unable to save lineup.");
|
setError(saveError instanceof Error ? saveError.message : "Unable to save lineup.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteSavedLineup(lineup: LineupRecord) {
|
||||||
|
if (!lineup._id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchJson(`/api/lineups/${lineup._id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
setMessage(`Deleted lineup #${lineup.lineupNumber}.`);
|
||||||
|
setLineupPendingDelete(null);
|
||||||
|
await loadSavedLineups(gameDate);
|
||||||
|
} catch (deleteError) {
|
||||||
|
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete lineup.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Generate Lineup"
|
title="Generate Lineup"
|
||||||
subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed."
|
subtitle="Build the game-day lineup, drag batting order into place, and save numbered versions."
|
||||||
>
|
>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={lineupPendingDelete !== null}
|
||||||
|
title="Delete Saved Lineup?"
|
||||||
|
description={
|
||||||
|
lineupPendingDelete
|
||||||
|
? `Delete lineup #${lineupPendingDelete.lineupNumber} for ${lineupPendingDelete.gameDate}?`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setLineupPendingDelete(null)}
|
||||||
|
onConfirm={() => (lineupPendingDelete ? void deleteSavedLineup(lineupPendingDelete) : undefined)}
|
||||||
|
/>
|
||||||
|
|
||||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||||
|
|
||||||
<SectionCard title="Game Setup" description="Start with the players who are available today.">
|
<SectionCard title="Game Setup" description="Keep the setup compact for quick game-day edits.">
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label>
|
<label>
|
||||||
<span className="field-label">Game Date</span>
|
<span className="field-label">Game Date</span>
|
||||||
<Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
|
<Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
|
||||||
@@ -131,17 +291,17 @@ export function LineupClient() {
|
|||||||
/>
|
/>
|
||||||
Show pitchers who need rest
|
Show pitchers who need rest
|
||||||
</label>
|
</label>
|
||||||
<div className="md:col-span-2 flex flex-wrap gap-3">
|
<div className="md:col-span-2 flex flex-wrap gap-2">
|
||||||
<Button disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
<Button size="sm" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
||||||
Generate Suggested Lineup
|
Generate
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" disabled={!generated} onClick={() => void save()}>
|
<Button size="sm" variant="secondary" disabled={!generated} onClick={() => void save()}>
|
||||||
Save Lineup
|
Save Version
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||||
{players.map((player) => {
|
{players.map((player) => {
|
||||||
const selected = selectedPlayerIds.includes(player._id);
|
const selected = selectedPlayerIds.includes(player._id);
|
||||||
return (
|
return (
|
||||||
@@ -149,8 +309,8 @@ export function LineupClient() {
|
|||||||
key={player._id}
|
key={player._id}
|
||||||
className={selected ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20" : ""}
|
className={selected ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20" : ""}
|
||||||
>
|
>
|
||||||
<CardContent className="flex items-start justify-between gap-4 pt-6">
|
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||||
<label className="flex flex-1 cursor-pointer items-start gap-4">
|
<label className="flex flex-1 cursor-pointer items-start gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected}
|
checked={selected}
|
||||||
@@ -164,10 +324,10 @@ export function LineupClient() {
|
|||||||
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<span className="block text-lg font-semibold">
|
<span className="block text-base font-semibold">
|
||||||
{formatPlayerName(player.firstName, player.lastName)}
|
{formatPlayerName(player.firstName, player.lastName)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-sm text-muted-foreground">
|
<span className="mt-1 block text-xs text-muted-foreground">
|
||||||
#{player.jerseyNumber} {player.primaryPosition}
|
#{player.jerseyNumber} {player.primaryPosition}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -182,25 +342,24 @@ export function LineupClient() {
|
|||||||
|
|
||||||
{generated ? (
|
{generated ? (
|
||||||
<>
|
<>
|
||||||
<SectionCard title="Batting Order" description="Adjust any slot manually before saving.">
|
<SectionCard title="Batting Order" description="Drag rows to reorder.">
|
||||||
<div className="divide-y divide-border">
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBattingDragEnd}>
|
||||||
|
<SortableContext items={generated.battingOrder.map((slot) => slot.playerId)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="grid gap-3">
|
||||||
{generated.battingOrder.map((slot, index) => (
|
{generated.battingOrder.map((slot, index) => (
|
||||||
<div key={`${slot.playerId}-${index}`} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center">
|
<SortableBattingOrderItem
|
||||||
<div>
|
key={slot.playerId}
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
slot={slot}
|
||||||
Spot
|
index={index}
|
||||||
</p>
|
selectedPlayers={selectedPlayers}
|
||||||
<p className="mt-1 text-lg font-semibold">{index + 1}</p>
|
onChange={(playerId) => {
|
||||||
</div>
|
const nextPlayer = selectedPlayers.find((player) => player._id === playerId);
|
||||||
<select
|
if (!nextPlayer || !generated) {
|
||||||
className="field-select"
|
return;
|
||||||
value={slot.playerId}
|
}
|
||||||
onChange={(event) => {
|
|
||||||
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
updateBattingOrder(
|
||||||
if (!nextPlayer) return;
|
generated.battingOrder.map((entry, entryIndex) =>
|
||||||
updateGenerated({
|
|
||||||
...generated,
|
|
||||||
battingOrder: generated.battingOrder.map((entry, entryIndex) =>
|
|
||||||
entryIndex === index
|
entryIndex === index
|
||||||
? {
|
? {
|
||||||
...entry,
|
...entry,
|
||||||
@@ -209,44 +368,29 @@ export function LineupClient() {
|
|||||||
}
|
}
|
||||||
: entry
|
: entry
|
||||||
)
|
)
|
||||||
});
|
);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{selectedPlayers.map((player) => (
|
|
||||||
<option key={player._id} value={player._id}>
|
|
||||||
{formatPlayerName(player.firstName, player.lastName)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard title="Defensive Alignment" description="Keep the fielding assignments editable.">
|
||||||
title="Defensive Alignment"
|
|
||||||
description="Assignments follow position preferences, but every field is editable."
|
|
||||||
>
|
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{FIELD_POSITIONS.map((position) => {
|
{FIELD_POSITIONS.map((position) => {
|
||||||
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
||||||
return (
|
return (
|
||||||
<div key={position} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center">
|
<div key={position} className="grid gap-3 py-3 md:grid-cols-[110px_1fr] md:items-center">
|
||||||
<div>
|
<p className="text-sm font-semibold">{position}</p>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
||||||
Position
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold">{position}</p>
|
|
||||||
</div>
|
|
||||||
<select
|
<select
|
||||||
className="field-select"
|
className="field-select"
|
||||||
value={assignment?.playerId ?? ""}
|
value={assignment?.playerId ?? ""}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
||||||
if (!nextPlayer) return;
|
if (!nextPlayer || !generated) return;
|
||||||
const nextAssignments = generated.defensiveAssignments.filter(
|
const nextAssignments = generated.defensiveAssignments.filter((entry) => entry.position !== position);
|
||||||
(entry) => entry.position !== position
|
|
||||||
);
|
|
||||||
nextAssignments.push({
|
nextAssignments.push({
|
||||||
playerId: nextPlayer._id,
|
playerId: nextPlayer._id,
|
||||||
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
|
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
|
||||||
@@ -282,10 +426,45 @@ export function LineupClient() {
|
|||||||
<SectionCard title="No Lineup Yet" description="Generate a lineup once your game-day roster is selected.">
|
<SectionCard title="No Lineup Yet" description="Generate a lineup once your game-day roster is selected.">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Waiting on lineup"
|
title="Waiting on lineup"
|
||||||
description="Select your active players, choose a starter, and generate a suggested batting order and field setup."
|
description="Select active players, choose a starter, then generate the first version."
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SectionCard title="Saved Lineups" description="Saved versions are numbered by game date and can be deleted.">
|
||||||
|
{savedLineups.length > 0 ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{savedLineups.map((lineup) => (
|
||||||
|
<Card key={lineup._id}>
|
||||||
|
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{lineup.gameDate} · Lineup #{lineup.lineupNumber}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{lineup.battingOrder.length} hitters · {lineup.defensiveAssignments.length} field assignments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setLineupPendingDelete(lineup)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="No saved lineups"
|
||||||
|
description="Save a generated lineup to keep numbered versions for this game date."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { SectionCard } from "@/components/SectionCard";
|
||||||
|
import { Alert } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { fetchJson } from "@/lib/client";
|
||||||
|
import { formatPlayerName } from "@/lib/utils";
|
||||||
|
import type { PitchingAppearanceRecord, PlayerRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
export function PitchTrackingClient() {
|
||||||
|
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 [appearanceForm, setAppearanceForm] = useState({
|
||||||
|
playerId: "",
|
||||||
|
gameDate: new Date().toISOString().slice(0, 10),
|
||||||
|
pitchCount: 20,
|
||||||
|
notes: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPlayers() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetchJson<PlayerRecord[]>("/api/players");
|
||||||
|
setPlayers(response);
|
||||||
|
if (response.length > 0) {
|
||||||
|
setAppearanceForm((current) => ({
|
||||||
|
...current,
|
||||||
|
playerId: current.playerId || response[0]._id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(loadError instanceof Error ? loadError.message : "Failed to load players.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPlayers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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="Pitch Tracking"
|
||||||
|
subtitle="Log the latest outing quickly so rest guidance stays accurate between games."
|
||||||
|
>
|
||||||
|
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||||
|
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
title="Log Appearance"
|
||||||
|
description="Keep this lightweight for fast updates from the dugout or phone."
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading players...</p>
|
||||||
|
) : players.length > 0 ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Player</span>
|
||||||
|
<select
|
||||||
|
className="field-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 className="field-label">Game Date</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={appearanceForm.gameDate}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="md:col-span-2">
|
||||||
|
<span className="field-label">Pitches Thrown</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={appearanceForm.pitchCount}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAppearanceForm((current) => ({
|
||||||
|
...current,
|
||||||
|
pitchCount: Number(event.target.value)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="md:col-span-2">
|
||||||
|
<span className="field-label">Notes</span>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={appearanceForm.notes}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAppearanceForm((current) => ({ ...current, notes: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Button onClick={() => void logAppearance()}>Save Pitching Appearance</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="No players to track yet"
|
||||||
|
description="Add players on the roster tab first, then return here to log outings."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard
|
||||||
|
title="Pitcher Snapshot"
|
||||||
|
description="Quick read of each player’s latest pitching status."
|
||||||
|
>
|
||||||
|
{players.length > 0 ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{players.map((player) => (
|
||||||
|
<Card key={player._id}>
|
||||||
|
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{formatPlayerName(player.firstName, player.lastName)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{player.pitcherAvailability.lastPitchedDate
|
||||||
|
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
|
||||||
|
: "No pitching history"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="inline-flex rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{player.pitcherAvailability.daysSinceLastAppearance === null
|
||||||
|
? "No history"
|
||||||
|
: `${player.pitcherAvailability.daysSinceLastAppearance}d`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="No pitching data yet"
|
||||||
|
description="Log the first appearance above once your roster is in place."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export function PlayerForm({
|
|||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label>
|
<label>
|
||||||
<span className="field-label">First Name</span>
|
<span className="field-label">First Name</span>
|
||||||
<Input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
<Input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ import { StatusBadge } from "@/components/StatusBadge";
|
|||||||
import { Alert } from "@/components/ui/alert";
|
import { Alert } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { fetchJson } from "@/lib/client";
|
import { fetchJson } from "@/lib/client";
|
||||||
import { formatPlayerName } from "@/lib/utils";
|
import { formatPlayerName } from "@/lib/utils";
|
||||||
import type { PitchingAppearanceRecord, PlayerInput, PlayerRecord } from "@/lib/types";
|
import type { PlayerInput, PlayerRecord } from "@/lib/types";
|
||||||
|
|
||||||
export function RosterClient() {
|
export function RosterClient() {
|
||||||
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
||||||
@@ -25,12 +23,6 @@ export function RosterClient() {
|
|||||||
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
|
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
|
||||||
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
||||||
const playerFormRef = useRef<HTMLDivElement | null>(null);
|
const playerFormRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [appearanceForm, setAppearanceForm] = useState({
|
|
||||||
playerId: "",
|
|
||||||
gameDate: new Date().toISOString().slice(0, 10),
|
|
||||||
pitchCount: 20,
|
|
||||||
notes: ""
|
|
||||||
});
|
|
||||||
|
|
||||||
const editingPlayer = useMemo(
|
const editingPlayer = useMemo(
|
||||||
() => players.find((player) => player._id === editingPlayerId) ?? null,
|
() => players.find((player) => player._id === editingPlayerId) ?? null,
|
||||||
@@ -43,9 +35,6 @@ export function RosterClient() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetchJson<PlayerRecord[]>("/api/players");
|
const response = await fetchJson<PlayerRecord[]>("/api/players");
|
||||||
setPlayers(response);
|
setPlayers(response);
|
||||||
if (response.length > 0 && !appearanceForm.playerId) {
|
|
||||||
setAppearanceForm((current) => ({ ...current, playerId: response[0]._id }));
|
|
||||||
}
|
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
setError(loadError instanceof Error ? loadError.message : "Failed to load roster.");
|
setError(loadError instanceof Error ? loadError.message : "Failed to load roster.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -107,19 +96,6 @@ export function RosterClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Roster Management"
|
title="Roster Management"
|
||||||
@@ -163,68 +139,6 @@ export function RosterClient() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionCard
|
|
||||||
title="Pitch Count Log"
|
|
||||||
description="Record the latest outing to keep rest recommendations accurate."
|
|
||||||
>
|
|
||||||
<div className="grid gap-5 md:grid-cols-3">
|
|
||||||
<label>
|
|
||||||
<span className="field-label">Player</span>
|
|
||||||
<select
|
|
||||||
className="field-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 className="field-label">Game Date</span>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={appearanceForm.gameDate}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className="field-label">Pitches Thrown</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={appearanceForm.pitchCount}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAppearanceForm((current) => ({
|
|
||||||
...current,
|
|
||||||
pitchCount: Number(event.target.value)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="md:col-span-3">
|
|
||||||
<span className="field-label">Notes</span>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
value={appearanceForm.notes}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAppearanceForm((current) => ({ ...current, notes: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="md:col-span-3">
|
|
||||||
<Button onClick={() => void logAppearance()} disabled={players.length === 0}>
|
|
||||||
Save Pitching Appearance
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Current Roster"
|
title="Current Roster"
|
||||||
description="Tap edit for fast changes and check pitcher status at a glance."
|
description="Tap edit for fast changes and check pitcher status at a glance."
|
||||||
@@ -232,7 +146,7 @@ export function RosterClient() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading roster...</p>
|
<p className="text-sm text-muted-foreground">Loading roster...</p>
|
||||||
) : players.length > 0 ? (
|
) : players.length > 0 ? (
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
{players.map((player) => (
|
{players.map((player) => (
|
||||||
<Card
|
<Card
|
||||||
key={player._id}
|
key={player._id}
|
||||||
@@ -242,10 +156,10 @@ export function RosterClient() {
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CardContent className="grid gap-5 pt-6">
|
<CardContent className="grid gap-4 pt-4 md:pt-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold">
|
<h3 className="text-lg font-semibold md:text-xl">
|
||||||
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
|
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
@@ -257,7 +171,7 @@ export function RosterClient() {
|
|||||||
<StatusBadge status={player.pitcherAvailability.status} />
|
<StatusBadge status={player.pitcherAvailability.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 rounded-[1.5rem] bg-muted/60 p-4 sm:grid-cols-3">
|
<div className="grid gap-3 rounded-[1.5rem] bg-muted/60 p-3 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
Bats/Throws
|
Bats/Throws
|
||||||
@@ -290,11 +204,11 @@ export function RosterClient() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="secondary" onClick={() => startEditingPlayer(player._id)}>
|
<Button size="sm" variant="secondary" onClick={() => startEditingPlayer(player._id)}>
|
||||||
{editingPlayerId === player._id ? "Editing" : "Edit"}
|
{editingPlayerId === player._id ? "Editing" : "Edit"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
|
<Button size="sm" variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ export function SectionCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="px-4 pb-3 pt-4 md:px-6 md:pb-4 md:pt-6">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">{children}</CardContent>
|
<CardContent className="space-y-4 px-4 pb-4 md:space-y-5 md:px-6 md:pb-6">{children}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
|
return <div className={cn("flex flex-col gap-1.5 p-4 md:p-6", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
@@ -26,5 +26,5 @@ export function CardDescription({ className, ...props }: React.HTMLAttributes<HT
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("px-6 pb-6", className)} {...props} />;
|
return <div className={cn("px-4 pb-4 md:px-6 md:pb-6", className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ export interface PitchingAppearanceRecord {
|
|||||||
|
|
||||||
export interface LineupRecord extends GeneratedLineup {
|
export interface LineupRecord extends GeneratedLineup {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
|
lineupNumber: number;
|
||||||
playerIds: string[];
|
playerIds: string[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerAvailabilitySummary {
|
export interface PlayerAvailabilitySummary {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Model, Schema, Types, model, models } from "mongoose";
|
|||||||
|
|
||||||
export interface LineupDocument {
|
export interface LineupDocument {
|
||||||
gameDate: Date;
|
gameDate: Date;
|
||||||
|
lineupNumber: number;
|
||||||
playerIds: Types.ObjectId[];
|
playerIds: Types.ObjectId[];
|
||||||
startingPitcherId?: Types.ObjectId | null;
|
startingPitcherId?: Types.ObjectId | null;
|
||||||
battingOrder: Array<{
|
battingOrder: Array<{
|
||||||
@@ -22,7 +23,8 @@ export interface LineupDocument {
|
|||||||
|
|
||||||
const lineupSchema = new Schema<LineupDocument>(
|
const lineupSchema = new Schema<LineupDocument>(
|
||||||
{
|
{
|
||||||
gameDate: { type: Date, required: true, unique: true, index: true },
|
gameDate: { type: Date, required: true, index: true },
|
||||||
|
lineupNumber: { type: Number, required: true, min: 1 },
|
||||||
playerIds: [{ type: Schema.Types.ObjectId, ref: "Player", required: true }],
|
playerIds: [{ type: Schema.Types.ObjectId, ref: "Player", required: true }],
|
||||||
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
|
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
|
||||||
battingOrder: [
|
battingOrder: [
|
||||||
@@ -45,4 +47,6 @@ const lineupSchema = new Schema<LineupDocument>(
|
|||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
lineupSchema.index({ gameDate: 1, lineupNumber: 1 }, { unique: true });
|
||||||
|
|
||||||
export const LineupModel = (models.Lineup as Model<LineupDocument>) || model<LineupDocument>("Lineup", lineupSchema);
|
export const LineupModel = (models.Lineup as Model<LineupDocument>) || model<LineupDocument>("Lineup", lineupSchema);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user