Updating
This commit is contained in:
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { GameModel } from "@/models/Game";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -47,6 +48,8 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{
|
||||
})
|
||||
);
|
||||
|
||||
await PitchingAppearanceModel.deleteMany({ gameId: game._id });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { serialize } from "@/lib/serialize";
|
||||
import { saveGameSchema } from "@/lib/validators";
|
||||
import { GameModel } from "@/models/Game";
|
||||
import { LineupModel } from "@/models/Lineup";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -81,6 +82,19 @@ export async function POST(request: Request) {
|
||||
})
|
||||
);
|
||||
|
||||
const pitchingEntries = payload.playerStats.filter((playerStat) => (playerStat.pitchCount ?? 0) > 0);
|
||||
if (pitchingEntries.length > 0) {
|
||||
await PitchingAppearanceModel.insertMany(
|
||||
pitchingEntries.map((playerStat) => ({
|
||||
playerId: playerStat.playerId,
|
||||
gameId: game._id,
|
||||
gameDate,
|
||||
pitchCount: playerStat.pitchCount,
|
||||
notes: payload.opponent ? `Scored game vs ${payload.opponent}` : "Scored game"
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(serialize(game.toObject()), { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { pitchingAppearanceSchema } from "@/lib/validators";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { id } = await params;
|
||||
const payload = pitchingAppearanceSchema.parse(await request.json());
|
||||
const appearance = await PitchingAppearanceModel.findByIdAndUpdate(
|
||||
id,
|
||||
{ ...payload, gameDate: new Date(payload.gameDate) },
|
||||
{ new: true, runValidators: true }
|
||||
).lean();
|
||||
|
||||
if (!appearance) {
|
||||
return NextResponse.json({ error: "Pitching appearance not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(serialize(appearance));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { id } = await params;
|
||||
const appearance = await PitchingAppearanceModel.findByIdAndDelete(id).lean();
|
||||
|
||||
if (!appearance) {
|
||||
return NextResponse.json({ error: "Pitching appearance not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export async function POST(request: Request) {
|
||||
const payload = pitchingAppearanceSchema.parse(await request.json());
|
||||
const appearance = await PitchingAppearanceModel.create({
|
||||
...payload,
|
||||
gameId: null,
|
||||
gameDate: new Date(payload.gameDate)
|
||||
});
|
||||
|
||||
|
||||
@@ -31,12 +31,33 @@ export async function GET(request: Request) {
|
||||
gameDate
|
||||
);
|
||||
|
||||
const latestAppearanceMap = Object.fromEntries(
|
||||
players.map((player) => {
|
||||
const latestAppearance = appearances
|
||||
.filter((appearance) => String(appearance.playerId) === String(player._id))
|
||||
.sort((a, b) => new Date(b.gameDate).getTime() - new Date(a.gameDate).getTime())[0];
|
||||
|
||||
return [
|
||||
String(player._id),
|
||||
latestAppearance
|
||||
? {
|
||||
appearanceId: String(latestAppearance._id),
|
||||
gameDate: toDateOnly(latestAppearance.gameDate),
|
||||
pitchCount: latestAppearance.pitchCount,
|
||||
notes: latestAppearance.notes ?? ""
|
||||
}
|
||||
: null
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
serialize(
|
||||
players.map((player) => ({
|
||||
...player,
|
||||
_id: String(player._id),
|
||||
pitcherAvailability: availabilityMap[String(player._id)]
|
||||
pitcherAvailability: availabilityMap[String(player._id)],
|
||||
latestPitching: latestAppearanceMap[String(player._id)]
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GameHistoryClient } from "@/components/GameHistoryClient";
|
||||
|
||||
export default function GameHistoryPage() {
|
||||
return <GameHistoryClient />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PitchTrackingClient } from "@/components/PitchTrackingClient";
|
||||
|
||||
export default function PitchTrackingPage() {
|
||||
return <PitchTrackingClient />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3, ClipboardList, FileText, ShieldCheck, TimerReset, Trophy, Users } from "lucide-react";
|
||||
import { BarChart3, ClipboardList, FileClock, FileText, ShieldCheck, Trophy, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -8,8 +8,8 @@ import { cn } from "@/lib/utils";
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 },
|
||||
{ href: "/roster", label: "Roster", icon: Users },
|
||||
{ href: "/pitching", label: "Pitch Tracking", icon: TimerReset },
|
||||
{ href: "/games", label: "Score Game", icon: FileText },
|
||||
{ href: "/games/history", label: "Past Games", icon: FileClock },
|
||||
{ href: "/lineup", label: "Generate Lineup", icon: ClipboardList },
|
||||
{ href: "/settings", label: "Pitching Rules", icon: ShieldCheck }
|
||||
];
|
||||
@@ -48,7 +48,7 @@ export function AppShell({
|
||||
<nav className="app-surface mt-3 flex flex-wrap gap-1.5 p-1.5">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href;
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, FileText, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
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 { fetchJson } from "@/lib/client";
|
||||
import type { GameRecord } from "@/lib/types";
|
||||
|
||||
const statFields = [
|
||||
["pitchCount", "Pitches"],
|
||||
["atBats", "AB"],
|
||||
["runs", "R"],
|
||||
["hits", "H"],
|
||||
["doubles", "2B"],
|
||||
["triples", "3B"],
|
||||
["homeRuns", "HR"],
|
||||
["runsBattedIn", "RBI"],
|
||||
["walks", "BB"],
|
||||
["strikeouts", "SO"],
|
||||
["stolenBases", "SB"]
|
||||
] as const;
|
||||
|
||||
export function GameHistoryClient() {
|
||||
const [gameDate, setGameDate] = useState("");
|
||||
const [games, setGames] = useState<GameRecord[]>([]);
|
||||
const [gamePendingDelete, setGamePendingDelete] = useState<GameRecord | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadGames(targetDate?: string) {
|
||||
try {
|
||||
const suffix = targetDate ? `?gameDate=${targetDate}` : "";
|
||||
const response = await fetchJson<GameRecord[]>(`/api/games${suffix}`);
|
||||
setGames(response);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load past games.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadGames();
|
||||
}, []);
|
||||
|
||||
async function deleteGame(game: GameRecord) {
|
||||
if (!game._id) return;
|
||||
|
||||
try {
|
||||
await fetchJson(`/api/games/${game._id}`, { method: "DELETE" });
|
||||
setMessage(`Deleted game #${game.gameNumber}.`);
|
||||
setGamePendingDelete(null);
|
||||
await loadGames(gameDate || undefined);
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete game.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Game History"
|
||||
subtitle="Review full past game details, including lineup used, score, notes, and every player stat line."
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={gamePendingDelete !== null}
|
||||
title="Delete Saved Game?"
|
||||
description={
|
||||
gamePendingDelete
|
||||
? `Delete game #${gamePendingDelete.gameNumber} for ${gamePendingDelete.gameDate}? Player stats from that game will be reversed.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete Game"
|
||||
onCancel={() => setGamePendingDelete(null)}
|
||||
onConfirm={() => (gamePendingDelete ? void deleteGame(gamePendingDelete) : undefined)}
|
||||
/>
|
||||
|
||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard
|
||||
title="Browse Games"
|
||||
description="Filter by date or leave it blank to browse every saved game."
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="min-w-[180px]">
|
||||
<span className="field-label">Game Date</span>
|
||||
<Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button onClick={() => void loadGames(gameDate || undefined)}>Apply Filter</Button>
|
||||
{gameDate ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setGameDate("");
|
||||
void loadGames();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Saved Games"
|
||||
description="Open any game card to inspect the full saved details."
|
||||
>
|
||||
{games.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{games.map((game) => (
|
||||
<Card key={game._id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-full bg-muted p-2 text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{game.gameDate} · Game #{game.gameNumber}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Lineup #{game.lineupNumber ?? "?"}
|
||||
{game.opponent ? ` · vs ${game.opponent}` : ""}
|
||||
{game.teamScore !== null &&
|
||||
game.teamScore !== undefined &&
|
||||
game.opponentScore !== null &&
|
||||
game.opponentScore !== undefined
|
||||
? ` · ${game.teamScore}-${game.opponentScore}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="gap-1"
|
||||
onClick={() => setGamePendingDelete(game)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<details className="mt-4 rounded-[1.25rem] border border-border bg-muted/30 px-4 py-3">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between text-sm font-medium">
|
||||
View Full Game Data
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</summary>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{game.notes ? (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Notes
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{game.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto rounded-[1rem] border border-border bg-white/70">
|
||||
<table className="min-w-full text-left text-xs">
|
||||
<thead className="border-b border-border bg-muted/60 uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">Player</th>
|
||||
{statFields.map(([, label]) => (
|
||||
<th key={label} className="px-3 py-2 font-semibold">
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{game.playerStats.map((playerStat) => (
|
||||
<tr key={playerStat.playerId}>
|
||||
<td className="px-3 py-2 font-medium">{playerStat.playerName}</td>
|
||||
{statFields.map(([field, label]) => (
|
||||
<td key={label} className="px-3 py-2">
|
||||
{playerStat[field] ?? "-"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No saved games"
|
||||
description="Score and save a game first, then its full details will appear here."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
@@ -15,6 +13,7 @@ import { fetchJson } from "@/lib/client";
|
||||
import type { GamePlayerStatsInput, GameRecord, LineupRecord } from "@/lib/types";
|
||||
|
||||
const statFields = [
|
||||
["pitchCount", "Pitches"],
|
||||
["atBats", "AB"],
|
||||
["runs", "R"],
|
||||
["hits", "H"],
|
||||
@@ -36,13 +35,12 @@ export function GameScoringClient() {
|
||||
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [lineups, setLineups] = useState<LineupRecord[]>([]);
|
||||
const [selectedLineupId, setSelectedLineupId] = useState("");
|
||||
const [savedGames, setSavedGames] = useState<GameRecord[]>([]);
|
||||
const [gamePendingDelete, setGamePendingDelete] = useState<GameRecord | null>(null);
|
||||
const [opponent, setOpponent] = useState("");
|
||||
const [teamScore, setTeamScore] = useState<string>("");
|
||||
const [opponentScore, setOpponentScore] = useState<string>("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [playerStats, setPlayerStats] = useState<Record<string, GamePlayerStatsInput>>({});
|
||||
const [isScoring, setIsScoring] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -50,27 +48,21 @@ export function GameScoringClient() {
|
||||
try {
|
||||
const response = await fetchJson<LineupRecord[]>("/api/lineups");
|
||||
setLineups(response);
|
||||
setSelectedLineupId((current) => (current && response.some((lineup) => lineup._id === current) ? current : pickDefaultLineupId(gameDate, response)));
|
||||
setSelectedLineupId((current) =>
|
||||
current && response.some((lineup) => lineup._id === current)
|
||||
? current
|
||||
: pickDefaultLineupId(gameDate, response)
|
||||
);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load lineups.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSavedGames(targetDate: string) {
|
||||
try {
|
||||
const response = await fetchJson<GameRecord[]>(`/api/games?gameDate=${targetDate}`);
|
||||
setSavedGames(response);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load games.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadLineups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSavedGames(gameDate);
|
||||
setSelectedLineupId((current) => {
|
||||
if (current && lineups.some((lineup) => lineup._id === current)) {
|
||||
return current;
|
||||
@@ -88,6 +80,7 @@ export function GameScoringClient() {
|
||||
useEffect(() => {
|
||||
if (!selectedLineup) {
|
||||
setPlayerStats({});
|
||||
setIsScoring(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,7 +97,12 @@ export function GameScoringClient() {
|
||||
);
|
||||
}, [selectedLineup]);
|
||||
|
||||
function updatePlayerStat(playerId: string, playerName: string, field: keyof Omit<GamePlayerStatsInput, "playerId" | "playerName">, value: string) {
|
||||
function updatePlayerStat(
|
||||
playerId: string,
|
||||
playerName: string,
|
||||
field: keyof Omit<GamePlayerStatsInput, "playerId" | "playerName">,
|
||||
value: string
|
||||
) {
|
||||
setPlayerStats((current) => ({
|
||||
...current,
|
||||
[playerId]: {
|
||||
@@ -130,7 +128,9 @@ export function GameScoringClient() {
|
||||
teamScore: teamScore === "" ? null : Number(teamScore),
|
||||
opponentScore: opponentScore === "" ? null : Number(opponentScore),
|
||||
notes,
|
||||
playerStats: selectedLineup.battingOrder.map((slot) => playerStats[slot.playerId] ?? { playerId: slot.playerId, playerName: slot.playerName })
|
||||
playerStats: selectedLineup.battingOrder.map(
|
||||
(slot) => playerStats[slot.playerId] ?? { playerId: slot.playerId, playerName: slot.playerName }
|
||||
)
|
||||
};
|
||||
|
||||
const saved = await fetchJson<GameRecord>("/api/games", {
|
||||
@@ -144,59 +144,47 @@ export function GameScoringClient() {
|
||||
setOpponentScore("");
|
||||
setNotes("");
|
||||
setPlayerStats({});
|
||||
await loadSavedGames(gameDate);
|
||||
setIsScoring(false);
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to save game.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGame(game: GameRecord) {
|
||||
if (!game._id) return;
|
||||
|
||||
try {
|
||||
await fetchJson(`/api/games/${game._id}`, { method: "DELETE" });
|
||||
setMessage(`Deleted game #${game.gameNumber}.`);
|
||||
setGamePendingDelete(null);
|
||||
await loadSavedGames(gameDate);
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete game.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Score Game"
|
||||
subtitle="Pick the lineup used, enter the results, and roll the stats into each player automatically."
|
||||
subtitle="Choose the lineup used, then open the scorecard only when you're ready to enter the game results."
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={gamePendingDelete !== null}
|
||||
title="Delete Saved Game?"
|
||||
description={
|
||||
gamePendingDelete
|
||||
? `Delete game #${gamePendingDelete.gameNumber} for ${gamePendingDelete.gameDate}? Player stats from that game will be reversed.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete Game"
|
||||
onCancel={() => setGamePendingDelete(null)}
|
||||
onConfirm={() => (gamePendingDelete ? void deleteGame(gamePendingDelete) : undefined)}
|
||||
/>
|
||||
|
||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard
|
||||
title="Game Setup"
|
||||
description="Choose the lineup used in the game. The latest saved lineup is selected by default."
|
||||
description="The most recent saved lineup is selected by default."
|
||||
>
|
||||
{lineups.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<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);
|
||||
setIsScoring(false);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Lineup Used</span>
|
||||
<select className="field-select" value={selectedLineupId} onChange={(event) => setSelectedLineupId(event.target.value)}>
|
||||
<select
|
||||
className="field-select"
|
||||
value={selectedLineupId}
|
||||
onChange={(event) => {
|
||||
setSelectedLineupId(event.target.value);
|
||||
setIsScoring(false);
|
||||
}}
|
||||
>
|
||||
{lineups.map((lineup) => (
|
||||
<option key={lineup._id} value={lineup._id}>
|
||||
{lineup.gameDate} · Lineup #{lineup.lineupNumber}
|
||||
@@ -222,6 +210,14 @@ export function GameScoringClient() {
|
||||
<span className="field-label">Game Notes</span>
|
||||
<Textarea rows={3} value={notes} onChange={(event) => setNotes(event.target.value)} />
|
||||
</label>
|
||||
<div className="md:col-span-2 flex flex-wrap gap-2">
|
||||
<Button onClick={() => setIsScoring(true)}>Start Scorecard</Button>
|
||||
{isScoring ? (
|
||||
<Button variant="secondary" onClick={() => setIsScoring(false)}>
|
||||
Hide Scorecard
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
@@ -231,14 +227,18 @@ export function GameScoringClient() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{selectedLineup ? (
|
||||
{selectedLineup && isScoring ? (
|
||||
<SectionCard
|
||||
title="Player Results"
|
||||
description="Stat entry is optional. Leave any field blank if you do not want to track it for that player."
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
{selectedLineup.battingOrder.map((slot, index) => {
|
||||
const statLine = playerStats[slot.playerId] ?? { playerId: slot.playerId, playerName: slot.playerName };
|
||||
const statLine = playerStats[slot.playerId] ?? {
|
||||
playerId: slot.playerId,
|
||||
playerName: slot.playerName
|
||||
};
|
||||
|
||||
return (
|
||||
<Card key={slot.playerId}>
|
||||
<CardContent className="grid gap-3 pt-4">
|
||||
@@ -251,7 +251,7 @@ export function GameScoringClient() {
|
||||
<p className="text-xs text-muted-foreground">Batting order slot {index + 1}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5 lg:grid-cols-6">
|
||||
{statFields.map(([field, label]) => (
|
||||
<label key={field}>
|
||||
<span className="field-label">{label}</span>
|
||||
@@ -259,7 +259,9 @@ export function GameScoringClient() {
|
||||
type="number"
|
||||
min={0}
|
||||
value={statLine[field] ?? ""}
|
||||
onChange={(event) => updatePlayerStat(slot.playerId, slot.playerName, field, event.target.value)}
|
||||
onChange={(event) =>
|
||||
updatePlayerStat(slot.playerId, slot.playerName, field, event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
@@ -274,48 +276,6 @@ export function GameScoringClient() {
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
<SectionCard
|
||||
title="Saved Games"
|
||||
description="Previously saved games for the selected date can be deleted."
|
||||
>
|
||||
{savedGames.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{savedGames.map((game) => (
|
||||
<Card key={game._id}>
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-full bg-muted p-2 text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{game.gameDate} · Game #{game.gameNumber}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Lineup #{game.lineupNumber ?? "?"}
|
||||
{game.opponent ? ` · vs ${game.opponent}` : ""}
|
||||
{game.teamScore !== null && game.teamScore !== undefined && game.opponentScore !== null && game.opponentScore !== undefined
|
||||
? ` · ${game.teamScore}-${game.opponentScore}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" className="gap-1" onClick={() => setGamePendingDelete(game)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No saved games"
|
||||
description="Score and save a game to keep a dated history here."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"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: ""
|
||||
});
|
||||
|
||||
const visiblePitchers = players.filter((player) => player.pitcherAvailability.status !== "unknown");
|
||||
|
||||
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."
|
||||
>
|
||||
{visiblePitchers.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{visiblePitchers.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 eligible pitchers to show"
|
||||
description="Pitchers with unknown availability are hidden from this snapshot."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
import type { PlayerInput, PlayerRecord } from "@/lib/types";
|
||||
import type { PlayerInput, PlayerPitchingInput, PlayerRecord } from "@/lib/types";
|
||||
|
||||
export function RosterClient() {
|
||||
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
||||
@@ -23,8 +25,23 @@ export function RosterClient() {
|
||||
const [showPlayerForm, setShowPlayerForm] = useState(false);
|
||||
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
|
||||
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
||||
const [pitchingForm, setPitchingForm] = useState<PlayerPitchingInput>({
|
||||
appearanceId: null,
|
||||
gameDate: null,
|
||||
pitchCount: null,
|
||||
notes: ""
|
||||
});
|
||||
const playerFormRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const statColumns = [
|
||||
["G", "gamesPlayed"],
|
||||
["AB", "atBats"],
|
||||
["R", "runs"],
|
||||
["H", "hits"],
|
||||
["RBI", "runsBattedIn"],
|
||||
["SB", "stolenBases"]
|
||||
] as const;
|
||||
|
||||
const editingPlayer = useMemo(
|
||||
() => players.find((player) => player._id === editingPlayerId) ?? null,
|
||||
[editingPlayerId, players]
|
||||
@@ -50,8 +67,22 @@ export function RosterClient() {
|
||||
useEffect(() => {
|
||||
if (editingPlayer) {
|
||||
setPlayerForm(toPlayerForm(editingPlayer));
|
||||
setPitchingForm(
|
||||
editingPlayer.latestPitching ?? {
|
||||
appearanceId: null,
|
||||
gameDate: null,
|
||||
pitchCount: null,
|
||||
notes: ""
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
setPitchingForm({
|
||||
appearanceId: null,
|
||||
gameDate: null,
|
||||
pitchCount: null,
|
||||
notes: ""
|
||||
});
|
||||
}
|
||||
}, [editingPlayer]);
|
||||
|
||||
@@ -73,11 +104,23 @@ export function RosterClient() {
|
||||
setEditingPlayerId(null);
|
||||
setShowPlayerForm(false);
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
setPitchingForm({
|
||||
appearanceId: null,
|
||||
gameDate: null,
|
||||
pitchCount: null,
|
||||
notes: ""
|
||||
});
|
||||
}
|
||||
|
||||
function startAddingPlayer() {
|
||||
setEditingPlayerId(null);
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
setPitchingForm({
|
||||
appearanceId: null,
|
||||
gameDate: null,
|
||||
pitchCount: null,
|
||||
notes: ""
|
||||
});
|
||||
setShowPlayerForm((current) => {
|
||||
const next = !current;
|
||||
if (!current) {
|
||||
@@ -95,9 +138,34 @@ export function RosterClient() {
|
||||
try {
|
||||
const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players";
|
||||
const method = editingPlayerId ? "PATCH" : "POST";
|
||||
await fetchJson<PlayerRecord>(url, { method, body: JSON.stringify(playerForm) });
|
||||
const savedPlayer = await fetchJson<PlayerRecord>(url, { method, body: JSON.stringify(playerForm) });
|
||||
|
||||
if (pitchingForm.appearanceId && !pitchingForm.gameDate && !pitchingForm.pitchCount) {
|
||||
await fetchJson(`/api/pitching-appearances/${pitchingForm.appearanceId}`, { method: "DELETE" });
|
||||
} else if (pitchingForm.gameDate && pitchingForm.pitchCount) {
|
||||
const payload = {
|
||||
playerId: savedPlayer._id,
|
||||
gameDate: pitchingForm.gameDate,
|
||||
pitchCount: pitchingForm.pitchCount,
|
||||
notes: pitchingForm.notes ?? ""
|
||||
};
|
||||
|
||||
if (pitchingForm.appearanceId) {
|
||||
await fetchJson(`/api/pitching-appearances/${pitchingForm.appearanceId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
await fetchJson("/api/pitching-appearances", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(editingPlayerId ? "Player updated." : "Player added.");
|
||||
setEditingPlayerId(null);
|
||||
setShowPlayerForm(false);
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
await loadPlayers();
|
||||
} catch (submitError) {
|
||||
@@ -157,6 +225,7 @@ export function RosterClient() {
|
||||
<th className="px-3 py-3 font-semibold">Player</th>
|
||||
<th className="px-3 py-3 font-semibold">Pos</th>
|
||||
<th className="px-3 py-3 font-semibold">B/T</th>
|
||||
<th className="px-3 py-3 font-semibold">Stats</th>
|
||||
<th className="px-3 py-3 font-semibold">Pitching</th>
|
||||
<th className="px-3 py-3 font-semibold">Status</th>
|
||||
<th className="px-3 py-3 font-semibold">Actions</th>
|
||||
@@ -183,6 +252,20 @@ export function RosterClient() {
|
||||
{player.tertiaryPosition ? <div>{player.tertiaryPosition}</div> : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">{player.batsThrows}</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{player.stats && Object.values(player.stats).some((value) => value !== null && value !== undefined) ? (
|
||||
<div className="grid grid-cols-3 gap-x-3 gap-y-1 text-xs">
|
||||
{statColumns.map(([label, key]) => (
|
||||
<div key={label}>
|
||||
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
|
||||
<span>{player.stats?.[key] ?? 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No stats</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-muted-foreground">
|
||||
{player.pitcherAvailability.lastPitchedDate
|
||||
? `${player.pitcherAvailability.lastPitchCount} on ${player.pitcherAvailability.lastPitchedDate}`
|
||||
@@ -233,14 +316,62 @@ export function RosterClient() {
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
<PlayerForm
|
||||
value={playerForm}
|
||||
onChange={setPlayerForm}
|
||||
onSubmit={() => void submitPlayer()}
|
||||
submitLabel={editingPlayerId ? "Save Changes" : "Add Player"}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<PlayerForm
|
||||
value={playerForm}
|
||||
onChange={setPlayerForm}
|
||||
onSubmit={() => void submitPlayer()}
|
||||
submitLabel={editingPlayerId ? "Save Changes" : "Add Player"}
|
||||
/>
|
||||
<div className="grid gap-4 rounded-[1.5rem] border border-border bg-muted/30 p-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-sm font-semibold">Latest Pitching Appearance</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Optional. Leave blank if the player has no recent outing to track.
|
||||
</p>
|
||||
</div>
|
||||
<label>
|
||||
<span className="field-label">Game Date</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={pitchingForm.gameDate ?? ""}
|
||||
onChange={(event) =>
|
||||
setPitchingForm((current) => ({
|
||||
...current,
|
||||
gameDate: event.target.value || null
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Pitch Count</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={pitchingForm.pitchCount ?? ""}
|
||||
onChange={(event) =>
|
||||
setPitchingForm((current) => ({
|
||||
...current,
|
||||
pitchCount: event.target.value === "" ? null : Number(event.target.value)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className="field-label">Pitching Notes</span>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={pitchingForm.notes ?? ""}
|
||||
onChange={(event) =>
|
||||
setPitchingForm((current) => ({
|
||||
...current,
|
||||
notes: event.target.value
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface PlayerStatsInput {
|
||||
stolenBases?: number | null;
|
||||
}
|
||||
|
||||
export interface PlayerPitchingInput {
|
||||
appearanceId?: string | null;
|
||||
gameDate?: string | null;
|
||||
pitchCount?: number | null;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface GamePlayerStatsInput {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
@@ -41,6 +48,7 @@ export interface GamePlayerStatsInput {
|
||||
walks?: number | null;
|
||||
strikeouts?: number | null;
|
||||
stolenBases?: number | null;
|
||||
pitchCount?: number | null;
|
||||
}
|
||||
|
||||
export interface PitchingRuleInput {
|
||||
@@ -67,6 +75,7 @@ export interface PlayerRecord extends PlayerInput {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
pitcherAvailability: PlayerAvailabilitySummary;
|
||||
latestPitching?: PlayerPitchingInput | null;
|
||||
}
|
||||
|
||||
export interface PitchingAppearanceInput {
|
||||
|
||||
@@ -90,7 +90,8 @@ export const saveGameSchema = z.object({
|
||||
runsBattedIn: optionalStat,
|
||||
walks: optionalStat,
|
||||
strikeouts: optionalStat,
|
||||
stolenBases: optionalStat
|
||||
stolenBases: optionalStat,
|
||||
pitchCount: optionalStat
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
@@ -54,7 +54,8 @@ const gameSchema = new Schema<GameDocument>(
|
||||
runsBattedIn: { type: Number, default: null },
|
||||
walks: { type: Number, default: null },
|
||||
strikeouts: { type: Number, default: null },
|
||||
stolenBases: { type: Number, default: null }
|
||||
stolenBases: { type: Number, default: null },
|
||||
pitchCount: { type: Number, default: null }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Model, Schema, Types, model, models } from "mongoose";
|
||||
|
||||
export interface PitchingAppearanceDocument {
|
||||
playerId: Types.ObjectId;
|
||||
gameId?: Types.ObjectId | null;
|
||||
gameDate: Date;
|
||||
pitchCount: number;
|
||||
notes?: string;
|
||||
@@ -12,6 +13,7 @@ export interface PitchingAppearanceDocument {
|
||||
const pitchingAppearanceSchema = new Schema<PitchingAppearanceDocument>(
|
||||
{
|
||||
playerId: { type: Schema.Types.ObjectId, ref: "Player", required: true, index: true },
|
||||
gameId: { type: Schema.Types.ObjectId, ref: "Game", default: null, index: true },
|
||||
gameDate: { type: Date, required: true, index: true },
|
||||
pitchCount: { type: Number, required: true },
|
||||
notes: { type: String, default: "" }
|
||||
|
||||
Reference in New Issue
Block a user