Compare commits
11
Commits
812bf9c652
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48023ace4 | ||
|
|
2263dc3aa5 | ||
|
|
1d587599fc | ||
|
|
02186dcdfa | ||
|
|
2ed854389c | ||
|
|
4d098a9ec9 | ||
|
|
9e56dae161 | ||
|
|
0de593c7d3 | ||
|
|
b05a1a954b | ||
|
|
ba3728ec34 | ||
|
|
7b25f249e3 |
+79
@@ -0,0 +1,79 @@
|
||||
using LCU.Api.Internal.FileOperations.Validations;
|
||||
using LCU.Library.Internal.FileOperationsModels.Models;
|
||||
using LCU.Library.Internal.LCUServiceFactory.Configurations;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LCU.Api.Internal.FileOperations.Services.FileActionServices
|
||||
{
|
||||
public class WriteBinaryFileService : FileActionService, IFileActionService, IActionService<WriteFileRequest>
|
||||
{
|
||||
public WriteBinaryFileService(ServicesFactoryConfiguration servicesFactoryConfiguration)
|
||||
: base(servicesFactoryConfiguration)
|
||||
{
|
||||
}
|
||||
|
||||
public RequestResponse ExecuteAction(QueueRequest queueRequest)
|
||||
{
|
||||
var requestResponse = new RequestResponse();
|
||||
|
||||
try
|
||||
{
|
||||
var fileRequest = JsonConvert.DeserializeObject<WriteFileRequest>(queueRequest.Request);
|
||||
|
||||
requestResponse = PerformRequest(fileRequest);
|
||||
|
||||
requestResponse.ReturnResponse = fileRequest;
|
||||
return requestResponse;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return RequestResponseHelper.ExceptionUpdateRequestResponse(requestResponse,
|
||||
queueRequest.FileOperationType,
|
||||
exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public RequestResponse PerformRequest(WriteFileRequest request)
|
||||
{
|
||||
return OperationService.PerformOperation(request, IsRequestValid, WriteBinaryFile);
|
||||
}
|
||||
|
||||
private void WriteBinaryFile(WriteFileRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileBytes = Convert.FromBase64String(request.FileData);
|
||||
File.WriteAllBytes(request.FullFilePath, fileBytes);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new Exception($"Exception thrown while trying to ExecuteRequestAction. " +
|
||||
$"Request: {JsonConvert.SerializeObject(request)}" +
|
||||
$"ExceptionMessage: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public RequestResponse PreQueueValidation(WriteFileRequest originalRequest)
|
||||
{
|
||||
var requestResponse = new RequestResponse();
|
||||
|
||||
requestResponse = IsRequestValid(originalRequest, requestResponse);
|
||||
|
||||
return requestResponse;
|
||||
}
|
||||
|
||||
public RequestResponse IsRequestValid(WriteFileRequest originalRequest, RequestResponse requestResponse)
|
||||
{
|
||||
requestResponse = FileValidation.ValidateDirectoryExists(originalRequest.DestinationPath, requestResponse);
|
||||
|
||||
if (!originalRequest.Overwrite)
|
||||
{
|
||||
requestResponse = FileValidation.ValidateFileNotExists(originalRequest.FullFilePath, requestResponse);
|
||||
}
|
||||
|
||||
return requestResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using LCU.Api.Internal.FileOperations.Services.FileActionServices;
|
||||
using LCU.Library.Internal.FileOperationsModels.Models;
|
||||
using LCU.Library.Internal.LCUServiceFactory.Configurations;
|
||||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace LCU.Api.Internal.FileOperationsTests.Services
|
||||
{
|
||||
public class WriteBinaryFileServiceTest : IDisposable
|
||||
{
|
||||
private readonly string _tempDirectory;
|
||||
private readonly WriteBinaryFileService _writeBinaryFileService;
|
||||
|
||||
public WriteBinaryFileServiceTest()
|
||||
{
|
||||
_tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
_writeBinaryFileService = new WriteBinaryFileService(new ServicesFactoryConfiguration());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformRequest_WritesBinaryBytes()
|
||||
{
|
||||
var expectedBytes = new byte[] { 0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00 };
|
||||
var request = new WriteFileRequest
|
||||
{
|
||||
DestinationPath = _tempDirectory,
|
||||
FileName = "sample",
|
||||
Extension = ".wav",
|
||||
FileData = Convert.ToBase64String(expectedBytes),
|
||||
Overwrite = true
|
||||
};
|
||||
|
||||
var result = _writeBinaryFileService.PerformRequest(request);
|
||||
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Equal(expectedBytes, File.ReadAllBytes(request.FullFilePath));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDirectory))
|
||||
{
|
||||
Directory.Delete(_tempDirectory, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS aspnetcore-build
|
||||
COPY LCU.API.Internal.MemberPreferencesApi/LCU.API.Internal.MemberPreferencesApi.csproj ./source/LCU.API.Internal.MemberPreferencesApi/
|
||||
COPY LCU.API.Internal.MemberPreferencesApiLib/LCU.API.Internal.MemberPreferencesApiLib.csproj ./source/LCU.API.Internal.MemberPreferencesApiLib/
|
||||
COPY LCU.Library.Internal.MemberPreferencesApiModels/LCU.Library.Internal.MemberPreferencesApiModels.csproj ./source/LCU.Library.Internal.MemberPreferencesApiModels/
|
||||
COPY LCU.API.Internal.MemberPreferencesApi.UnitTests/LCU.API.Internal.MemberPreferencesApi.UnitTests.csproj ./source/LCU.API.Internal.MemberPreferencesApi.UnitTests/
|
||||
|
||||
# Install Credential Provider and set env variables to enable Nuget restore with auth
|
||||
ARG PAT=bh2lq3jibktw67b2wdbqyfln7cd3je6lk52kijntpj43v6kimpgq
|
||||
@@ -20,7 +19,10 @@ RUN dotnet restore -s "https://landmarkcu.pkgs.visualstudio.com/_packaging/LCU-I
|
||||
COPY . ./source/
|
||||
|
||||
LABEL test=true
|
||||
COPY LCU.API.Internal.MemberPreferencesApi.UnitTests/LCU.API.Internal.MemberPreferencesApi.UnitTests.csproj ./source/LCU.API.Internal.MemberPreferencesApi.UnitTests/
|
||||
|
||||
RUN dotnet restore -s "https://landmarkcu.pkgs.visualstudio.com/_packaging/LCU-Internal/nuget/v3/index.json" -s "https://api.nuget.org/v3/index.json" ./source/LCU.API.Internal.MemberPreferencesApi.UnitTests/LCU.API.Internal.MemberPreferencesApi.UnitTests.csproj
|
||||
|
||||
RUN dotnet test -c Release --results-directory /testresults --logger "trx;LogFileName=test_results.trx" ./source/LCU.API.Internal.MemberPreferencesApi.UnitTests/LCU.API.Internal.MemberPreferencesApi.UnitTests.csproj
|
||||
|
||||
# .NET Core Publish
|
||||
|
||||
+1
-4
@@ -18,8 +18,6 @@ namespace LCU.API.Internal.MemberPreferencesApi.IntegrationTests
|
||||
private Mock<IUnifiedOptInRepository> _mockUnifiedOptinRepository;
|
||||
private Mock<IMemberPreferenceRepository> _mockMemberPreferenceRepository;
|
||||
private Mock<IFileOperationsApiService> _mockFileOperationsApiService;
|
||||
private IMock<IXdiBatchProcessInfoRepository> _mockXdiBatchProcessInfoRepository;
|
||||
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
@@ -27,7 +25,6 @@ namespace LCU.API.Internal.MemberPreferencesApi.IntegrationTests
|
||||
_mockFileOperationsApiService = new Mock<IFileOperationsApiService>();
|
||||
_mockUnifiedOptinRepository = new Mock<IUnifiedOptInRepository>();
|
||||
_mockBatchItemsRepository = new Mock<IXdiBatchItemsRepository>();
|
||||
_mockXdiBatchProcessInfoRepository = new Mock<IXdiBatchProcessInfoRepository>();
|
||||
_mockUnitOfWork = new Mock<IUnitOfWork>();
|
||||
_mockUnifiedOptinRepository = new Mock<IUnifiedOptInRepository>();
|
||||
_mockMemberPreferenceRepository = new Mock<IMemberPreferenceRepository>();
|
||||
@@ -35,7 +32,7 @@ namespace LCU.API.Internal.MemberPreferencesApi.IntegrationTests
|
||||
|
||||
_xdiBatchItemService = new XdiBatchService(_mockBatchItemsRepository.Object, _mockUnitOfWork.Object,
|
||||
_mockUnifiedOptinRepository.Object, _mockMemberPreferenceRepository.Object,
|
||||
_mockFileOperationsApiService.Object, _mockXdiBatchProcessInfoRepository.Object);
|
||||
_mockFileOperationsApiService.Object);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
-1
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="LCU.Library.Internal.CoreApiWrapperModel" Version="2.27.14" />
|
||||
<PackageReference Include="LCU.Library.Internal.LcuClassPatterns.CrudTests" Version="1.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
|
||||
+32
-30
@@ -3,8 +3,8 @@ using LCU.API.Internal.MemberPreferencesApi.Models;
|
||||
using LCU.API.Internal.MemberPreferencesApi.Models.Requests;
|
||||
using LCU.API.Internal.MemberPreferencesApi.Services.XdiServices;
|
||||
using LCU.API.Internal.MemberPreferencesApiLib.Repositories;
|
||||
using LCU.Library.Internal.LcuClassPatterns.CrudTests.Tests;
|
||||
using LCU.Library.Internal.LcuClassPatterns.CrudTests.Utilities;
|
||||
using LCU.Library.Internal.ApiResponse.Models;
|
||||
using LCU.Library.Internal.FileOperationsModels.Models;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
|
||||
using Moq;
|
||||
@@ -17,11 +17,10 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
|
||||
{
|
||||
public class XdiBatchItemServiceTest : CrudServiceTest<XdiBatchProcessInfo>
|
||||
public class XdiBatchItemServiceTest
|
||||
{
|
||||
private IXdiBatchService _xdiBatchItemService;
|
||||
private Mock<IXdiBatchItemsRepository> _mockBatchItemsRepository;
|
||||
private Mock<IXdiBatchProcessInfoRepository> _mockXdiBatchProcessInfoRepository;
|
||||
private Mock<IUnitOfWork> _mockUnitOfWork;
|
||||
private Mock<IUnifiedOptInRepository> _mockUnifiedOptinRepository;
|
||||
private Mock<IMemberPreferenceRepository> _mockMemberPreferenceRepository;
|
||||
@@ -31,7 +30,6 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
|
||||
public void Setup()
|
||||
{
|
||||
_mockBatchItemsRepository = new Mock<IXdiBatchItemsRepository>();
|
||||
_mockXdiBatchProcessInfoRepository = new Mock<IXdiBatchProcessInfoRepository>();
|
||||
_mockUnitOfWork = new Mock<IUnitOfWork>();
|
||||
_mockUnifiedOptinRepository = new Mock<IUnifiedOptInRepository>();
|
||||
_mockMemberPreferenceRepository = new Mock<IMemberPreferenceRepository>();
|
||||
@@ -39,7 +37,7 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
|
||||
|
||||
_xdiBatchItemService = new XdiBatchService(_mockBatchItemsRepository.Object, _mockUnitOfWork.Object,
|
||||
_mockUnifiedOptinRepository.Object, _mockMemberPreferenceRepository.Object,
|
||||
_mockFileOperationsApiService.Object, _mockXdiBatchProcessInfoRepository.Object);
|
||||
_mockFileOperationsApiService.Object);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -305,14 +303,17 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
|
||||
UniversalOptIn = DeliveryPreference.Hold
|
||||
});
|
||||
|
||||
var xdiBatchProcessInfo = new List<XdiBatchProcessInfo>
|
||||
var appendFileResponse = new RequestResponse
|
||||
{
|
||||
new()
|
||||
IsSuccessful = true,
|
||||
ReturnResponse = new ReturnResponse
|
||||
{
|
||||
XdiBatchProcessInfoId = (int)typeof(int).GetRandomValue(),
|
||||
StartTimeStamp = DateTime.Now,
|
||||
XdiBatchProcessStatus = XdiBatchProcessStatus.InProgress,
|
||||
ApiResponse = "Started"
|
||||
ResponseItems = new List<ResponseItem>() {
|
||||
new()
|
||||
{
|
||||
ResponsePayload = "File Created"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -321,10 +322,9 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
|
||||
XdiAppendFileRootBasePath = "test/test"
|
||||
};
|
||||
|
||||
_mockXdiBatchProcessInfoRepository.Setup(x => x.FindBatchProcessInfoByStatus(XdiBatchProcessStatus.InProgress)).ReturnsAsync(xdiBatchProcessInfo)
|
||||
.Verifiable();
|
||||
_mockFileOperationsApiService.Setup(x => x.AppendFile(It.IsAny<AppendFileRequest>())).ReturnsAsync(appendFileResponse).Verifiable();
|
||||
|
||||
var results = await _xdiBatchItemService.BuildXdiFiles(testFile, request.XdiAppendFileRootBasePath, xdiBatchProcessInfo.FirstOrDefault());
|
||||
var results = await _xdiBatchItemService.BuildXdiFiles(testFile, request.XdiAppendFileRootBasePath);
|
||||
|
||||
Assert.AreEqual(4, results.Count);
|
||||
Assert.True(results.Exists(x => x.UserId == "100" && x.Value == "[email protected]"));
|
||||
@@ -347,17 +347,6 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
|
||||
}
|
||||
};
|
||||
|
||||
var xdiBatchProcessInfo = new List<XdiBatchProcessInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
XdiBatchProcessInfoId = (int)typeof(int).GetRandomValue(),
|
||||
StartTimeStamp = DateTime.Now,
|
||||
XdiBatchProcessStatus = XdiBatchProcessStatus.InProgress,
|
||||
ApiResponse = "Started"
|
||||
}
|
||||
};
|
||||
|
||||
_mockUnifiedOptinRepository
|
||||
.Setup(x => x.GetMemberOptInSettingsNoTracking("100")).ReturnsAsync(new MemberOptInSettings
|
||||
{
|
||||
@@ -369,15 +358,28 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
|
||||
.Setup(x => x.GetMemberOptInSettingsNoTracking(It.Is<string>(userId => int.Parse(userId) > 100)))
|
||||
.Returns(Task.FromResult<MemberOptInSettings>(null));
|
||||
|
||||
_mockXdiBatchProcessInfoRepository.Setup(x => x.FindBatchProcessInfoByStatus(XdiBatchProcessStatus.InProgress)).ReturnsAsync(xdiBatchProcessInfo)
|
||||
.Verifiable();
|
||||
|
||||
var request = new XdiBatchRequest()
|
||||
{
|
||||
XdiAppendFileRootBasePath = "test/test"
|
||||
};
|
||||
|
||||
var results = await _xdiBatchItemService.BuildXdiFiles(testFile, request.XdiAppendFileRootBasePath, xdiBatchProcessInfo.FirstOrDefault());
|
||||
var appendFileResponse = new RequestResponse
|
||||
{
|
||||
IsSuccessful = true,
|
||||
ReturnResponse = new ReturnResponse
|
||||
{
|
||||
ResponseItems = new List<ResponseItem>() {
|
||||
new()
|
||||
{
|
||||
ResponsePayload = "File Created"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_mockFileOperationsApiService.Setup(x => x.AppendFile(It.IsAny<AppendFileRequest>())).ReturnsAsync(appendFileResponse).Verifiable();
|
||||
|
||||
var results = await _xdiBatchItemService.BuildXdiFiles(testFile, request.XdiAppendFileRootBasePath);
|
||||
|
||||
Assert.AreEqual(1, results.Count);
|
||||
Assert.True(results.Exists(x => x.UserId == "100" && x.Value == DeliveryPreference.Paper));
|
||||
|
||||
-53
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Linq;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
|
||||
using LCU.API.Internal.MemberPreferencesApi.Models;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace LCU.API.Internal.MemberPreferencesApi.UnitTests
|
||||
@@ -162,57 +161,5 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests
|
||||
Assert.AreEqual(1, files.Count);
|
||||
Assert.AreEqual(XdiFileType.UpdateMemberEmail, files.First().Type);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Enrollment_Microfiche()
|
||||
{
|
||||
var batch = new Models.XdiBatch();
|
||||
var settings = new MemberOptInSettings()
|
||||
{
|
||||
Id = 1000,
|
||||
UniversalOptIn = DeliveryPreference.Microfiche,
|
||||
ENotifications = DeliveryPreference.Electronic,
|
||||
EStatements = DeliveryPreference.Electronic,
|
||||
UnifiedOptInEmail = "[email protected]",
|
||||
ENotificationsEmail = "[email protected]",
|
||||
EStatementsEmail = "[email protected]",
|
||||
UserId = "99"
|
||||
};
|
||||
|
||||
var result = batch.ProcessItem(_item, settings);
|
||||
|
||||
Assert.True(result);
|
||||
var files = batch.GetNonEmptyFiles();
|
||||
Assert.NotNull(files);
|
||||
Assert.AreEqual(1, files.Count);
|
||||
Assert.AreEqual(XdiFileType.UpdateMemberToPrint, files.First().Type);
|
||||
Assert.AreEqual("[email protected]", files.First().Data.First().Email);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_Enrollment_Hold()
|
||||
{
|
||||
var batch = new Models.XdiBatch();
|
||||
var settings = new MemberOptInSettings()
|
||||
{
|
||||
Id = 1000,
|
||||
UniversalOptIn = DeliveryPreference.Hold,
|
||||
ENotifications = DeliveryPreference.Electronic,
|
||||
EStatements = DeliveryPreference.Electronic,
|
||||
UnifiedOptInEmail = "[email protected]",
|
||||
ENotificationsEmail = "[email protected]",
|
||||
EStatementsEmail = "[email protected]",
|
||||
UserId = "99"
|
||||
};
|
||||
|
||||
var result = batch.ProcessItem(_item, settings);
|
||||
|
||||
Assert.True(result);
|
||||
var files = batch.GetNonEmptyFiles();
|
||||
Assert.NotNull(files);
|
||||
Assert.AreEqual(1, files.Count);
|
||||
Assert.AreEqual(XdiFileType.UpdateMemberToPrint, files.First().Type);
|
||||
Assert.AreEqual("[email protected]", files.First().Data.First().Email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-8
@@ -21,14 +21,6 @@ public class DnaAuditProcessConsumer : IConsumer<DnaAuditProcessRequest>
|
||||
await scope.ServiceProvider.GetRequiredService<IDNADataAuditProcessService>().HandleDnaAuditProcessRequest(context.Message);
|
||||
Log.Debug("Ending DnaAuditProcessConsumer.");
|
||||
}
|
||||
catch (ServiceException serviceException)
|
||||
{
|
||||
Log.ForContext(Enum.GetName(typeof(PropertyEnum), PropertyEnum.Group),
|
||||
Enum.GetName(typeof(GroupEnum), GroupEnum.ITDevelopment))
|
||||
.Error(serviceException, $"DnaAuditProcess Failure. " +
|
||||
$"The request is placed on DnaAuditProcess_error.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.ForContext(Enum.GetName(typeof(PropertyEnum), PropertyEnum.Group),
|
||||
|
||||
-9
@@ -17,15 +17,6 @@ public class XdiBatchProcessConsumer : IConsumer<XdiBatchProcessRequest>
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
await Task.Run(async () => await scope.ServiceProvider.GetRequiredService<IXdiBatchProcessService>().HandleXdiBatchProcess(context.Message));
|
||||
}
|
||||
catch (ServiceException ex)
|
||||
{
|
||||
Log.ForContext(Enum.GetName(typeof(PropertyEnum), PropertyEnum.Group),
|
||||
Enum.GetName(typeof(GroupEnum), GroupEnum.ITDevelopment))
|
||||
.Error($"XdiBatchProcessConsumer Failure. " +
|
||||
$"The response is placed on LCU.MemberPreferencesApi.XdiBatchProcess_error. " +
|
||||
$"Error Message: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Log.ForContext(Enum.GetName(typeof(PropertyEnum), PropertyEnum.Group),
|
||||
|
||||
+2
@@ -1,8 +1,10 @@
|
||||
using LCU.API.Internal.MemberPreferencesApi.Services.QueueServices;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.FeatureManagement.Mvc;
|
||||
|
||||
namespace LCU.API.Internal.MemberPreferencesApi.Controllers;
|
||||
|
||||
[FeatureGate(FeatureGates.EnableControllers)]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class QueueXdiBatchProcessController : ControllerBase
|
||||
|
||||
-1
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="LCU.Api.Internal.MaidBotLib" Version="1.0.2" />
|
||||
<PackageReference Include="LCU.Library.Internal.Exceptions" Version="2.0.0" />
|
||||
<PackageReference Include="LCU.Library.Internal.FileOperationsModels" Version="4.1.5" />
|
||||
<PackageReference Include="LCU.Library.Internal.LcuClassPatterns.Crud" Version="1.0.2" />
|
||||
<PackageReference Include="LCU.Library.Internal.LcuHttpClient" Version="1.21.0" />
|
||||
<PackageReference Include="LCU.Library.Internal.LcuMassTransit" Version="1.0.0" />
|
||||
<PackageReference Include="LCU.Library.Internal.LcuMassTransitModels" Version="1.1.0" />
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ public class XdiBatch
|
||||
}
|
||||
|
||||
if (currentSettings.UniversalOptIn == DeliveryPreference.Microfiche &&
|
||||
currentSettings.ENotifications == DeliveryPreference.Electronic)
|
||||
currentSettings.ENotifications == DeliveryPreference.Electronic)
|
||||
{
|
||||
_updateMemberToPrint.AddRow(item.UserId, currentSettings.UnifiedOptInEmail);
|
||||
return true;
|
||||
|
||||
@@ -245,7 +245,6 @@ void AddMemberPreferencesLib(ref IServiceCollection services)
|
||||
services.AddScoped<IMemberPreferenceRepository, MemberPreferenceRepository>();
|
||||
services.AddScoped<IMemberPreferences, MemberPreferenceRepository>();
|
||||
services.AddScoped<IXdiBatchItemsRepository, XdiBatchItemsRepository>();
|
||||
services.AddScoped<IXdiBatchProcessInfoRepository, XdiBatchProcessInfoRepository>();
|
||||
services.AddScoped<IDnaAuditProcessRepository, DnaAuditProcessRepository>();
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -50,7 +50,6 @@ public class DNADataAuditProcessService : IDNADataAuditProcessService
|
||||
Log.Error(ex.Message);
|
||||
// Return successful as True so Dispatcher job never stops running.
|
||||
await QueueReturnResponse.QueueResponse(request, true, new List<string>() { ex.Message });
|
||||
throw new ServiceException($"Failed to handled Consumed DnaAuditProcessRequest. ExceptionMessage: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ namespace LCU.API.Internal.MemberPreferencesApi.MicroApiServices
|
||||
{
|
||||
var result = await _fileOperationsApiDao.AppendFileAsync(request);
|
||||
|
||||
Log.Information($"XDI File Sent, Response: {result.ReturnResponse}");
|
||||
Log.Information($"XDI File Saved. Response: {result.ReturnResponse}");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
using LCU.API.Internal.MemberPreferencesApi.Models;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Responses;
|
||||
|
||||
namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices;
|
||||
|
||||
@@ -7,9 +8,9 @@ public interface IXdiBatchService
|
||||
{
|
||||
Task AddXdiBatchItem(XdiBatchItem newItem);
|
||||
Task SaveRangeAsync(List<XdiBatchItem> xdiBatchItemList);
|
||||
Task CreateXdiBatchFiles(XdiBatchRequest request);
|
||||
Task<RequestResponse> CreateXdiBatchFiles(XdiBatchRequest request);
|
||||
Task<XdiBatchRequest> BuildXdiBatchRequest(XdiBatchRequest request);
|
||||
Task<List<XdiBatchFileExport>> BuildXdiFiles(XdiBatchFile batchFile, string filePath, XdiBatchProcessInfo xdiBatchProcessInfo);
|
||||
Task<List<XdiBatchFileExport>> BuildXdiFiles(XdiBatchFile batchFile, string filePath);
|
||||
Task<bool> MarkPendingSuccessful(List<XdiBatchFileExport> batchFileExportList);
|
||||
Task<bool> DoesMemberHavePendingXdiChange(string memberId);
|
||||
}
|
||||
|
||||
+2
-3
@@ -36,8 +36,8 @@ public class XdiBatchProcessService : IXdiBatchProcessService
|
||||
XdiAppendFileRootBasePath = xdiBatchProcessRequest.XdiAppendFileRootBasePath
|
||||
};
|
||||
|
||||
await _xdiBatchService.CreateXdiBatchFiles(request);
|
||||
await QueueReturnResponse.QueueResponse(xdiBatchProcessRequest, true, new List<string> { $"Requested {xdiBatchItems.Count()} Xdi Batch Items to Process."});
|
||||
var result = await _xdiBatchService.CreateXdiBatchFiles(request);
|
||||
await QueueReturnResponse.QueueResponse(xdiBatchProcessRequest, result.IsSuccessful, result.MessageStack);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -45,7 +45,6 @@ public class XdiBatchProcessService : IXdiBatchProcessService
|
||||
var errorMessage = $"Failed to HandleXdiBatchProcess. Exception Message: {ex.Message}";
|
||||
Log.Error(errorMessage);
|
||||
await QueueReturnResponse.QueueResponse(xdiBatchProcessRequest, false, new List<string>() {errorMessage});
|
||||
throw new ServiceException(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-77
@@ -1,17 +1,15 @@
|
||||
using LCU.API.Internal.MemberPreferencesApi.MicroApiServices;
|
||||
using LCU.API.Internal.MemberPreferencesApi.Models;
|
||||
using LCU.API.Internal.MemberPreferencesApiLib.Repositories;
|
||||
using LCU.Library.Internal.FileOperationsModels.Models;
|
||||
using LCU.Library.Internal.LcuClassPatterns.Crud.Services;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
|
||||
using System.Data.SqlClient;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Responses;
|
||||
using FileOperationsModels = LCU.Library.Internal.FileOperationsModels.Models;
|
||||
|
||||
namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices
|
||||
{
|
||||
public class XdiBatchService : CrudService<XdiBatchProcessInfo>, IXdiBatchService
|
||||
public class XdiBatchService : IXdiBatchService
|
||||
{
|
||||
private readonly IXdiBatchProcessInfoRepository _xdiBatchProcessInfoRepository;
|
||||
private readonly IXdiBatchItemsRepository _xdiBatchItemsRepository;
|
||||
private readonly IUnifiedOptInRepository _unifiedOptInRepository;
|
||||
private readonly IMemberPreferenceRepository _memberPreferenceRepository;
|
||||
@@ -21,44 +19,37 @@ namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices
|
||||
|
||||
public XdiBatchService(IXdiBatchItemsRepository xdiBatchItemsRepository,
|
||||
IUnitOfWork unitOfWork, IUnifiedOptInRepository unifiedOptInRepository,
|
||||
IMemberPreferenceRepository memberPreferencesRepository, IFileOperationsApiService fileOperationsApiService,
|
||||
IXdiBatchProcessInfoRepository xdiBatchProcessInfoRepository) : base(xdiBatchProcessInfoRepository)
|
||||
IMemberPreferenceRepository memberPreferencesRepository, IFileOperationsApiService fileOperationsApiService)
|
||||
{
|
||||
_xdiBatchItemsRepository = xdiBatchItemsRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_memberPreferenceRepository = memberPreferencesRepository;
|
||||
_unifiedOptInRepository = unifiedOptInRepository;
|
||||
_fileOperationsApiService = fileOperationsApiService;
|
||||
_xdiBatchProcessInfoRepository = xdiBatchProcessInfoRepository;
|
||||
}
|
||||
|
||||
public async Task CreateXdiBatchFiles(XdiBatchRequest request)
|
||||
public async Task<RequestResponse> CreateXdiBatchFiles(XdiBatchRequest request)
|
||||
{
|
||||
var batchProcessInfo = new XdiBatchProcessInfo();
|
||||
var batchUserIds = string.Join(",", request.XdiBatchItems.Select(c => c.UserId).ToList());
|
||||
var requestResponse = new RequestResponse();
|
||||
try
|
||||
{
|
||||
var batchProcessInProgress = _xdiBatchProcessInfoRepository.FindBatchProcessInfoByStatus(XdiBatchProcessStatus.InProgress).Result;
|
||||
if (batchProcessInProgress.Count() == 0)
|
||||
{
|
||||
request = await BuildXdiBatchRequest(request);
|
||||
batchProcessInfo = await CreateNewBatchProcessInfo(request.XdiBatchItems.Count());
|
||||
await CreateXdiExportFileRequest(request, batchProcessInfo);
|
||||
|
||||
await UpdateBatchAsync(batchProcessInfo, XdiBatchProcessStatus.Completed, $"Completed XDI Batch Process for UserIds: {batchUserIds}");
|
||||
}
|
||||
request = await BuildXdiBatchRequest(request);
|
||||
requestResponse = await CreateXdiExportFileRequest(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, $"Failed to Create XDI Batch Files.");
|
||||
await UpdateBatchAsync(batchProcessInfo, XdiBatchProcessStatus.Failed, $"Failed XDI Batch Process for UserIds: {batchUserIds}");
|
||||
throw new ServiceException($"Failed to Create XDI Batch Files. Exception Message: {ex.Message}");
|
||||
var batchUserIds = string.Join(",", request.XdiBatchItems.Select(c => c.UserId).ToList());
|
||||
var errorMessage = $"Failed to Create XDI Batch Files for UserId(s): {batchUserIds} Error: { ex.Message}";
|
||||
Log.Error(errorMessage);
|
||||
requestResponse.MessageStack.Add(errorMessage);
|
||||
requestResponse.IsSuccessful = false;
|
||||
}
|
||||
return requestResponse;
|
||||
}
|
||||
|
||||
public async Task<XdiBatchRequest> BuildXdiBatchRequest(XdiBatchRequest request)
|
||||
{
|
||||
foreach (var item in request.XdiBatchItems)
|
||||
foreach (var item in request.XdiBatchItems)
|
||||
{
|
||||
var currentSettings = await _unifiedOptInRepository.GetMemberOptInSettings(item.UserId);
|
||||
if (currentSettings != null)
|
||||
@@ -81,45 +72,62 @@ namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices
|
||||
Log.Error($"No XdiBatchRow or XDI File was processed based on current member UserId: {item.UserId} Opt In Settings, marking complete.");
|
||||
_xdiBatchItemsRepository.Delete(item);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
;
|
||||
}
|
||||
;
|
||||
|
||||
await _unitOfWork.CompleteAsync(XdiBatchProcessOperatorName, DateTime.UtcNow, UserType.System);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
public async Task CreateXdiExportFileRequest(XdiBatchRequest request, XdiBatchProcessInfo xdiBatchProcessInfo)
|
||||
public async Task<RequestResponse> CreateXdiExportFileRequest(XdiBatchRequest request)
|
||||
{
|
||||
var requestResponse = new RequestResponse();
|
||||
try
|
||||
{
|
||||
IReadOnlyCollection<XdiBatchFile> batchFiles = request.XdiBatch.GetNonEmptyFiles();
|
||||
Log.Information($"XdiBatchService produced {batchFiles.Count} files");
|
||||
var batchFilesMessage = ($"XdiBatchService produced {batchFiles.Count} file(s)");
|
||||
Log.Information(batchFilesMessage);
|
||||
|
||||
foreach (var xdiBatchFile in batchFiles)
|
||||
{
|
||||
var xdiBatchFileExport = await BuildXdiFiles(xdiBatchFile, request.XdiAppendFileRootBasePath, xdiBatchProcessInfo);
|
||||
await MarkPendingSuccessful(xdiBatchFileExport);
|
||||
var xdiBatchFileExport = await BuildXdiFiles(xdiBatchFile, request.XdiAppendFileRootBasePath);
|
||||
if (xdiBatchFileExport != null)
|
||||
{
|
||||
await MarkPendingSuccessful(xdiBatchFileExport);
|
||||
}
|
||||
}
|
||||
}
|
||||
requestResponse.IsSuccessful = true;
|
||||
requestResponse.MessageStack.Add(batchFilesMessage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, $"Failed to Create the XDI File to export.");
|
||||
var errorMessage = $"Failed to Create the XDI File to export. {ex.Message}";
|
||||
Log.Error(errorMessage);
|
||||
requestResponse.MessageStack.Add(errorMessage);
|
||||
requestResponse.IsSuccessful = false;
|
||||
}
|
||||
return requestResponse;
|
||||
}
|
||||
|
||||
public async Task<List<XdiBatchFileExport>> BuildXdiFiles(XdiBatchFile batchFile, string filePath, XdiBatchProcessInfo xdiBatchProcessInfo)
|
||||
public async Task<List<XdiBatchFileExport>> BuildXdiFiles(XdiBatchFile batchFile, string filePath)
|
||||
{
|
||||
AppendFileRequest request = BuildFileOperationsRequest(batchFile, filePath);
|
||||
FileOperationsModels.AppendFileRequest request = BuildFileOperationsRequest(batchFile, filePath);
|
||||
var xdiBatchFileExport = await CreateDirectoryXdiFiles(batchFile);
|
||||
var result = await _fileOperationsApiService.AppendFile(request);
|
||||
|
||||
if (!result.IsSuccessful)
|
||||
{
|
||||
throw new Exception($"Failed to Append File: {batchFile.GetFileName}");
|
||||
}
|
||||
|
||||
return xdiBatchFileExport;
|
||||
}
|
||||
|
||||
private AppendFileRequest BuildFileOperationsRequest(XdiBatchFile xdiBatchFile, string filePath)
|
||||
private FileOperationsModels.AppendFileRequest BuildFileOperationsRequest(XdiBatchFile xdiBatchFile, string filePath)
|
||||
{
|
||||
var request = new AppendFileRequest()
|
||||
var request = new FileOperationsModels.AppendFileRequest()
|
||||
{
|
||||
DestinationPath = filePath,
|
||||
Extension = ".txt",
|
||||
@@ -256,45 +264,5 @@ namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<XdiBatchProcessInfo> CreateNewBatchProcessInfo(int batchTotal)
|
||||
{
|
||||
var xdiBatchProcessInfo = new XdiBatchProcessInfo();
|
||||
xdiBatchProcessInfo.StartTimeStamp = DateTime.Now;
|
||||
xdiBatchProcessInfo.XdiBatchProcessStatus = XdiBatchProcessStatus.InProgress;
|
||||
xdiBatchProcessInfo.BatchTotal = batchTotal;
|
||||
return await _xdiBatchProcessInfoRepository.AddAsync(xdiBatchProcessInfo);
|
||||
}
|
||||
|
||||
public async Task<XdiBatchProcessInfo> UpdateBatchAsync(XdiBatchProcessInfo xdiBatchProcessInfo, XdiBatchProcessStatus xdiBatchStatus, string apiResponse)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _xdiBatchProcessInfoRepository.FindByIdAsync(xdiBatchProcessInfo.XdiBatchProcessInfoId);
|
||||
if (existing == null)
|
||||
{
|
||||
throw new ServiceException($"XdiBatchProcessInfo with XdiBatchProcessInfoId {xdiBatchProcessInfo.XdiBatchProcessInfoId} not found");
|
||||
}
|
||||
existing.XdiBatchProcessStatus = xdiBatchStatus;
|
||||
existing.ApiResponse = apiResponse;
|
||||
existing.EndTimeStamp = DateTime.Now;
|
||||
|
||||
await _xdiBatchProcessInfoRepository.UpdateAsync(existing);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return existing;
|
||||
}
|
||||
catch (SqlException sqlEx)
|
||||
{
|
||||
var message = $"Failure updating XdiBatchProcessInfo with XdiBatchProcessInfoId {xdiBatchProcessInfo.XdiBatchProcessInfoId}";
|
||||
Log.Error(sqlEx, message);
|
||||
throw new ServiceException(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var message = $"Failure updating XdiBatchProcessInfo with XdiBatchProcessInfoId {xdiBatchProcessInfo.XdiBatchProcessInfoId}";
|
||||
Log.Error(ex, message);
|
||||
throw new ServiceException(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
"AppendFile": "DirectFileOperations/AppendFile"
|
||||
},
|
||||
"CoreApiWrapperConfiguration": {
|
||||
"CoreApiBaseUrl": "https://qa-core-api-wrapper-api.apps.azure.lcu-internal.com/api/core/",
|
||||
"CoreApiBaseUrl": "https://localhost:44355/api/core/",
|
||||
"CoreApiGetAgreementByUserId": "Agree/AgreementByUserId/{userId}",
|
||||
"CoreApiGetAgreementByAgreementId": "Agree/AgreementByAgreementId/{agreementId}",
|
||||
"CoreApiGetAgreementsByAccountId": "Agree/AgreementsByAccountId/{accountId}",
|
||||
|
||||
-4
@@ -18,7 +18,6 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
|
||||
public DbSet<MemberOptInSettings> MemberOptInSettings { get; set; }
|
||||
public DbSet<DnaAuditProcess> DnaAuditProcesses { get; set; }
|
||||
public DbSet<XdiBatchItem> XdiBatchItems { get; set; }
|
||||
public DbSet<XdiBatchProcessInfo> XdiBatchProcessInfos { get; set; }
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
|
||||
{
|
||||
@@ -104,8 +103,5 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
|
||||
|
||||
builder.Entity<DnaAuditProcess>().ToTable("DnaAuditProcess");
|
||||
builder.Entity<DnaAuditProcess>().HasKey(p => p.ProcessId);
|
||||
|
||||
builder.Entity<XdiBatchProcessInfo>().ToTable("XdiBatchProcessInfo");
|
||||
builder.Entity<XdiBatchProcessInfo>().HasKey(p => p.XdiBatchProcessInfoId);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -11,7 +11,6 @@ public interface IApplicationDbContext : IAuditableChanges
|
||||
DbSet<MemberOptInSettings> MemberOptInSettings { get; set; }
|
||||
public DbSet<XdiBatchItem> XdiBatchItems { get; set; }
|
||||
public DbSet<DnaAuditProcess> DnaAuditProcesses { get; set; }
|
||||
public DbSet<XdiBatchProcessInfo> XdiBatchProcessInfos { get; set; }
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken());
|
||||
Task<int> SaveChangesWithOperatorAsync(string operatorId, DateTime dateAdded, UserType userType, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
using LCU.Library.Internal.LcuClassPatterns.Crud.Repositories;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
|
||||
|
||||
namespace LCU.API.Internal.MemberPreferencesApiLib.Repositories;
|
||||
|
||||
public interface IXdiBatchProcessInfoRepository: ICrudRepository<XdiBatchProcessInfo>
|
||||
{
|
||||
Task<IEnumerable<XdiBatchProcessInfo>> FindBatchProcessInfoByStatus(XdiBatchProcessStatus xdiProcessStatus);
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
using LCU.API.Internal.MemberPreferencesApiLib.Persistence;
|
||||
using LCU.Library.Internal.LcuClassPatterns.Crud.Repositories;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LCU.API.Internal.MemberPreferencesApiLib.Repositories;
|
||||
|
||||
public class XdiBatchProcessInfoRepository : CrudRepository<XdiBatchProcessInfo>, IXdiBatchProcessInfoRepository
|
||||
{
|
||||
private readonly IApplicationDbContext _context;
|
||||
public XdiBatchProcessInfoRepository(ApplicationDbContext context) : base(context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<XdiBatchProcessInfo>> FindBatchProcessInfoByStatus(XdiBatchProcessStatus xdiProcessStatus)
|
||||
{
|
||||
return await _context.XdiBatchProcessInfos.Where(p => p.XdiBatchProcessStatus == xdiProcessStatus).ToListAsync();
|
||||
}
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Operations Version="1.0" xmlns="http://schemas.microsoft.com/sqlserver/dac/Serialization/2012/02">
|
||||
<Operation Name="Rename Refactor" Key="5c3556a6-05fd-442f-a0af-06c6e69d6a9d" ChangeDateTime="03/24/2026 00:12:28">
|
||||
<Property Name="ElementName" Value="[dbo].[XdiBatchProcessInfo].[Id]" />
|
||||
<Property Name="ElementType" Value="SqlSimpleColumn" />
|
||||
<Property Name="ParentElementName" Value="[dbo].[XdiBatchProcessInfo]" />
|
||||
<Property Name="ParentElementType" Value="SqlTable" />
|
||||
<Property Name="NewName" Value="XdiBatchProcessInfoId" />
|
||||
</Operation>
|
||||
</Operations>
|
||||
-4
@@ -63,9 +63,5 @@
|
||||
<Build Include="MemberOptInSettings.sql" />
|
||||
<Build Include="MemberPreference.sql" />
|
||||
<Build Include="XdiBatchItems.sql" />
|
||||
<Build Include="XdiBatchProcessInfo.sql" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<RefactorLog Include="LCU.API.Internal.MemberPreferencesApiSql.refactorlog" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE [dbo].[XdiBatchProcessInfo]
|
||||
(
|
||||
[XdiBatchProcessInfoId] [int] NOT NULL PRIMARY KEY IDENTITY,
|
||||
[StartTimeStamp] DATETIME2 NOT NULL,
|
||||
[EndTimeStamp] DATETIME2 NULL,
|
||||
[BatchTotal] INT NOT NULL DEFAULT 0,
|
||||
[ApiResponse] NVARCHAR(MAX) NULL,
|
||||
[XdiBatchProcessStatus] INT NOT NULL
|
||||
)
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LCU.Library.Internal.MemberPreferencesApiModels.Entities
|
||||
{
|
||||
public class XdiBatchProcessInfo
|
||||
{
|
||||
[Key]
|
||||
public int XdiBatchProcessInfoId { get; set; }
|
||||
public DateTime StartTimeStamp { get; set; }
|
||||
public DateTime EndTimeStamp { get; set; }
|
||||
public XdiBatchProcessStatus XdiBatchProcessStatus { get; set; }
|
||||
public int BatchTotal { get; set; }
|
||||
public string ApiResponse { get; set; }
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
namespace LCU.Library.Internal.MemberPreferencesApiModels.Enums
|
||||
{
|
||||
public enum XdiBatchProcessStatus
|
||||
{
|
||||
InProgress,
|
||||
Failed,
|
||||
Completed
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
driver: secrets-store.csi.k8s.io
|
||||
readOnly: true
|
||||
volumeAttributes:
|
||||
secretProviderClass: member-preferences-api-csi-key-vault-secrets-provider
|
||||
secretProviderClass: normal-member-preferences-api-csi-key-vault-secrets-provider
|
||||
|
||||
- op: add
|
||||
path: /spec/template/spec/containers/0/volumeMounts/-
|
||||
|
||||
+1
-3
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"Primary": {
|
||||
"FeatureFlags": {
|
||||
"RunHostedServices": true,
|
||||
"RunXdiBatchProcessConsumer": true,
|
||||
"RunXdiSshResponseConsumer": true,
|
||||
"RunXdiBatchProcessConsumer": false,
|
||||
"RunDnaAuditProcessConsumer": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- op: replace
|
||||
path: /spec/replicas
|
||||
value: 3
|
||||
@@ -0,0 +1,3 @@
|
||||
- op: replace
|
||||
path: /spec/replicas
|
||||
value: 3
|
||||
@@ -0,0 +1,3 @@
|
||||
- op: replace
|
||||
path: /spec/replicas
|
||||
value: 3
|
||||
@@ -1,6 +1,10 @@
|
||||
# This import was successfully run on 4/17/25
|
||||
# Commenting it out so that the scheduled pipelines can run
|
||||
|
||||
import {
|
||||
to = azuredevops_git_repository.repositories["LCU.WebApps.Collections.RepoClient"]
|
||||
id = "2aeafa30-011a-4751-8b26-ebf29243ae67/LCU.WebApps.Collections.RepoClient"
|
||||
}
|
||||
#import {
|
||||
# to = azuredevops_git_repository.repositories["LCU.API.Internal.MaidBotApi"]
|
||||
# id = "2aeafa30-011a-4751-8b26-ebf29243ae67/LCU.API.Internal.MaidBotApi"
|
||||
|
||||
@@ -128,6 +128,8 @@ aks_workloads:
|
||||
- path: \AKS\maidbot-api
|
||||
repo: LCU.API.Internal.MaidBotApi
|
||||
environment: DB
|
||||
- path: \AKS\repo-manager
|
||||
repo: LCU.WebApps.Collections.RepoClient
|
||||
|
||||
environments:
|
||||
- DB
|
||||
|
||||
@@ -374,6 +374,29 @@ describe('CreditApplicationService', () => {
|
||||
|
||||
});
|
||||
|
||||
it('should clear stale co-applicant spouse state when switching back to a single applicant', () => {
|
||||
const marriedPrimaryApplication: any = JSON.parse(JSON.stringify(primaryAndCoApplicationAddressSameApplication));
|
||||
spyOn(creditApplicationService, 'getFullCreditApplication').and.returnValue(marriedPrimaryApplication);
|
||||
spyOn(creditApplicationService, 'saveCreditApplication').and.returnValue(of(true));
|
||||
|
||||
const memberInfo: MembershipInformation = {
|
||||
limitRequested: marriedPrimaryApplication.limitRequested,
|
||||
applicantType: CreditApplicationValues.CHOICE_NO,
|
||||
livesInMaritalPropertyState: CreditApplicationValues.CHOICE_YES,
|
||||
confirmJointCredit: null,
|
||||
coApplicantLivesInMaritalPropertyState: null,
|
||||
purposeOfLoanSignature: null,
|
||||
agreementRequirement: undefined
|
||||
};
|
||||
|
||||
creditApplicationService.saveMemberInformation(memberInfo);
|
||||
|
||||
expect(creditApplicationService.getFullCreditApplication().fullInfoBorrowers.length).toBe(1);
|
||||
expect(creditApplicationService.getFullCreditApplication().hasCoApplicantSpouse).toBeNull();
|
||||
expect(creditApplicationService.getFullCreditApplication().coApplicantLivesInMaritalPropertyState).toBeNull();
|
||||
expect(creditApplicationService.getFullCreditApplication().confirmJointCredit).toBeNull();
|
||||
});
|
||||
|
||||
it('should set Estimated Value & Property Type defaults on HELOC Rate Mod loan type', () => {
|
||||
const loanConfigurationService = TestBed.get(ConfigurationService);
|
||||
loanConfigurationService.saveConfiguration(DefaultSettings as UiSettings);
|
||||
|
||||
@@ -368,6 +368,9 @@ export class CreditApplicationService {
|
||||
if (creditApplication.hasCoApplicant === CreditApplicationValues.CHOICE_NO &&
|
||||
creditApplication.fullInfoBorrowers.length > 1) {
|
||||
creditApplication.fullInfoBorrowers.pop();
|
||||
creditApplication.hasCoApplicantSpouse = null;
|
||||
creditApplication.coApplicantLivesInMaritalPropertyState = null;
|
||||
creditApplication.confirmJointCredit = null;
|
||||
}
|
||||
if (memberInfo.applicantType === CreditApplicationValues.CHOICE_YES &&
|
||||
memberInfo.coApplicantLivesInMaritalPropertyState === CreditApplicationValues.CHOICE_YES) {
|
||||
|
||||
+2
@@ -31,6 +31,8 @@ RUN dotnet publish -c Release -o /app/publish
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
|
||||
RUN apk add --no-cache icu-libs && apk add --no-cache tzdata;
|
||||
ENV TZ=America/Chicago
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
ENV ASPNETCORE_URLS=http://+:80
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
MONGODB_URI=mongodb://127.0.0.1:27017/little-league-roster
|
||||
@@ -0,0 +1,5 @@
|
||||
.next
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
coverage
|
||||
@@ -0,0 +1,82 @@
|
||||
# Coach Connor's Lineup Lab
|
||||
|
||||
Mobile-friendly baseball coaching app for roster management, scorekeeping, pitcher rest tracking, configurable rest rules, and game-day lineup planning.
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js App Router with React and TypeScript
|
||||
- MongoDB with Mongoose
|
||||
- Node.js API routes
|
||||
|
||||
## Features
|
||||
|
||||
- Manage players with positions, handedness, notes, and active status
|
||||
- Log pitching appearances and pitch counts by game date
|
||||
- Configure pitch-count-based rest rules
|
||||
- See pitcher availability as `Available`, `Needs Rest`, or `Unknown`
|
||||
- Generate suggested batting orders and defensive assignments
|
||||
- Manually adjust generated lineups before saving
|
||||
- Placeholder auth utility for future expansion
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
cd baseball
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Create environment variables:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Update `MONGODB_URI` in `.env` if needed.
|
||||
|
||||
Example:
|
||||
|
||||
```env
|
||||
MONGODB_URI=mongodb://127.0.0.1:27017/little-league-roster
|
||||
```
|
||||
|
||||
4. Start the app:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Open `http://localhost:3000`.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` starts the development server
|
||||
- `npm run build` builds the app
|
||||
- `npm run start` runs the production build
|
||||
|
||||
## Data Models
|
||||
|
||||
- `Player`
|
||||
- `PitchingAppearance`
|
||||
- `PitchingRule`
|
||||
- `Game`
|
||||
- `Lineup`
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```text
|
||||
baseball/
|
||||
src/
|
||||
app/ Next.js pages and API routes
|
||||
components/ Reusable UI and screen components
|
||||
lib/ DB, validation, client helpers, lineup logic
|
||||
models/ Mongoose schemas
|
||||
scripts/ Seed script
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- No authentication is enforced yet. `src/lib/auth.ts` provides a placeholder for future auth integration.
|
||||
- API routes return basic validation and server errors in a simple JSON format.
|
||||
- Pitcher eligibility is based on the most recent pitching appearance and the configured rule table.
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+3363
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "coach-connors-lineup-lab",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"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",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mongoose": "^8.14.3",
|
||||
"next": "^15.3.2",
|
||||
"postcss": "^8.5.14",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.17",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.4",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export function handleApiError(error: unknown) {
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json({ error: error.issues[0]?.message ?? "Validation failed." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unexpected server error." }, { status: 500 });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { buildPitcherAvailabilityMap } from "@/lib/pitching";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import type { DashboardResponse } from "@/lib/types";
|
||||
import { formatPlayerName, toDateOnly } from "@/lib/utils";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const gameDate = searchParams.get("gameDate") ?? toDateOnly(new Date());
|
||||
|
||||
const [players, appearances, rules] = await Promise.all([
|
||||
PlayerModel.find().sort({ jerseyNumber: 1 }).lean(),
|
||||
PitchingAppearanceModel.find().lean(),
|
||||
PitchingRuleModel.find().sort({ sortOrder: 1 }).lean()
|
||||
]);
|
||||
|
||||
const availabilityMap = buildPitcherAvailabilityMap(
|
||||
players.map((player) => ({ ...player, _id: String(player._id) })),
|
||||
appearances.map((appearance) => ({ ...appearance, playerId: String(appearance.playerId) })),
|
||||
rules,
|
||||
gameDate
|
||||
);
|
||||
|
||||
let availablePitchers = 0;
|
||||
let needsRest = 0;
|
||||
let unknownPitchers = 0;
|
||||
|
||||
const recentPitching = players.map((player) => {
|
||||
const summary = availabilityMap[String(player._id)];
|
||||
if (summary.status === "available") availablePitchers += 1;
|
||||
if (summary.status === "needs-rest") needsRest += 1;
|
||||
if (summary.status === "unknown") unknownPitchers += 1;
|
||||
|
||||
return {
|
||||
playerId: String(player._id),
|
||||
playerName: formatPlayerName(player.firstName, player.lastName),
|
||||
status: summary.status,
|
||||
daysSinceLastAppearance: summary.daysSinceLastAppearance,
|
||||
requiredRestDays: summary.requiredRestDays,
|
||||
lastPitchedDate: summary.lastPitchedDate,
|
||||
pitchCount: summary.lastPitchCount
|
||||
};
|
||||
});
|
||||
|
||||
const response: DashboardResponse = {
|
||||
rosterCount: players.length,
|
||||
activePlayers: players.filter((player) => player.isActive).length,
|
||||
availablePitchers,
|
||||
needsRest,
|
||||
unknownPitchers,
|
||||
recentPitching
|
||||
};
|
||||
|
||||
return NextResponse.json(serialize(response));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const statFields = [
|
||||
"atBats",
|
||||
"runs",
|
||||
"hits",
|
||||
"doubles",
|
||||
"triples",
|
||||
"homeRuns",
|
||||
"runsBattedIn",
|
||||
"walks",
|
||||
"strikeouts",
|
||||
"stolenBases",
|
||||
"inningsPitchedOuts",
|
||||
"earnedRunsAllowed",
|
||||
"hitsAllowed",
|
||||
"walksAllowed",
|
||||
"strikeoutsPitched"
|
||||
] as const;
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { id } = await params;
|
||||
const game = await GameModel.findByIdAndDelete(id).lean();
|
||||
|
||||
if (!game) {
|
||||
return NextResponse.json({ error: "Game not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
await PlayerModel.bulkWrite(
|
||||
game.playerStats.map((playerStat) => {
|
||||
const decrement: Record<string, number> = { "stats.gamesPlayed": -1 };
|
||||
|
||||
for (const field of statFields) {
|
||||
decrement[`stats.${field}`] = -(playerStat[field] ?? 0);
|
||||
}
|
||||
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { _id: playerStat.playerId },
|
||||
update: { $inc: decrement }
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
await PitchingAppearanceModel.deleteMany({ gameId: game._id });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
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";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const statFields = [
|
||||
"atBats",
|
||||
"runs",
|
||||
"hits",
|
||||
"doubles",
|
||||
"triples",
|
||||
"homeRuns",
|
||||
"runsBattedIn",
|
||||
"walks",
|
||||
"strikeouts",
|
||||
"stolenBases",
|
||||
"inningsPitchedOuts",
|
||||
"earnedRunsAllowed",
|
||||
"hitsAllowed",
|
||||
"walksAllowed",
|
||||
"strikeoutsPitched"
|
||||
] as const;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const gameDate = searchParams.get("gameDate");
|
||||
const filter = gameDate ? { gameDate: new Date(gameDate) } : {};
|
||||
const games = await GameModel.find(filter).sort({ gameDate: -1, gameNumber: -1 }).lean();
|
||||
return NextResponse.json(serialize(games));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = saveGameSchema.parse(await request.json());
|
||||
const lineup = await LineupModel.findById(payload.lineupId).lean();
|
||||
|
||||
if (!lineup) {
|
||||
return NextResponse.json({ error: "Selected lineup not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
const gameDate = new Date(payload.gameDate);
|
||||
const latestForDate = await GameModel.findOne({ gameDate }).sort({ gameNumber: -1 }).select("gameNumber").lean();
|
||||
const gameNumber = (latestForDate?.gameNumber ?? 0) + 1;
|
||||
|
||||
const game = await GameModel.create({
|
||||
gameDate,
|
||||
gameNumber,
|
||||
activePlayerIds: lineup.playerIds,
|
||||
startingPitcherId: lineup.startingPitcherId ?? null,
|
||||
lineupId: lineup._id,
|
||||
lineupNumber: lineup.lineupNumber ?? null,
|
||||
opponent: payload.opponent ?? "",
|
||||
teamScore: payload.teamScore ?? null,
|
||||
opponentScore: payload.opponentScore ?? null,
|
||||
notes: payload.notes ?? "",
|
||||
playerStats: payload.playerStats
|
||||
});
|
||||
|
||||
await PlayerModel.bulkWrite(
|
||||
payload.playerStats.map((playerStat) => {
|
||||
const increment: Record<string, number> = { "stats.gamesPlayed": 1 };
|
||||
|
||||
for (const field of statFields) {
|
||||
increment[`stats.${field}`] = playerStat[field] ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { _id: playerStat.playerId },
|
||||
update: { $inc: increment }
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
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,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { generateLineup } from "@/lib/lineup";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import type { Position } from "@/lib/types";
|
||||
import { generateLineupSchema } from "@/lib/validators";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = generateLineupSchema.parse(await request.json());
|
||||
|
||||
const [players, rules, appearances] = await Promise.all([
|
||||
PlayerModel.find({ _id: { $in: payload.playerIds } }).lean(),
|
||||
PitchingRuleModel.find().sort({ sortOrder: 1 }).lean(),
|
||||
PitchingAppearanceModel.find({ playerId: { $in: payload.playerIds } }).lean()
|
||||
]);
|
||||
|
||||
const lineup = generateLineup({
|
||||
players: players.map((player) => ({
|
||||
...player,
|
||||
_id: String(player._id),
|
||||
primaryPosition: player.primaryPosition as Position,
|
||||
secondaryPosition: (player.secondaryPosition ?? null) as Position | null,
|
||||
tertiaryPosition: (player.tertiaryPosition ?? null) as Position | null
|
||||
})),
|
||||
rules,
|
||||
appearances: appearances.map((appearance) => ({
|
||||
...appearance,
|
||||
playerId: String(appearance.playerId)
|
||||
})),
|
||||
gameDate: payload.gameDate,
|
||||
startingPitcherId: payload.startingPitcherId ?? null
|
||||
});
|
||||
|
||||
return NextResponse.json(serialize(lineup));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { saveLineupSchema } from "@/lib/validators";
|
||||
import { GameModel } from "@/models/Game";
|
||||
import { LineupModel } from "@/models/Lineup";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const gameDate = searchParams.get("gameDate");
|
||||
const filter = gameDate ? { gameDate: new Date(gameDate) } : {};
|
||||
const lineups = await LineupModel.find(filter).sort({ gameDate: -1, lineupNumber: -1 }).lean();
|
||||
return NextResponse.json(serialize(lineups));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
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 = {
|
||||
gameDate,
|
||||
lineupNumber,
|
||||
playerIds: payload.playerIds,
|
||||
startingPitcherId: payload.startingPitcherId ?? null,
|
||||
battingOrder: payload.battingOrder,
|
||||
defensiveAssignments: payload.defensiveAssignments,
|
||||
bench: payload.bench,
|
||||
notes: payload.notes
|
||||
};
|
||||
|
||||
const lineup = await LineupModel.create(lineupPayload);
|
||||
|
||||
await GameModel.findOneAndUpdate(
|
||||
{ gameDate: lineupPayload.gameDate },
|
||||
{
|
||||
gameDate: lineupPayload.gameDate,
|
||||
activePlayerIds: payload.playerIds,
|
||||
startingPitcherId: payload.startingPitcherId ?? null
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return NextResponse.json(serialize(lineup.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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 GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const playerId = searchParams.get("playerId");
|
||||
const filter = playerId ? { playerId } : {};
|
||||
const appearances = await PitchingAppearanceModel.find(filter).sort({ gameDate: -1 }).lean();
|
||||
return NextResponse.json(serialize(appearances));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = pitchingAppearanceSchema.parse(await request.json());
|
||||
const appearance = await PitchingAppearanceModel.create({
|
||||
...payload,
|
||||
gameId: null,
|
||||
gameDate: new Date(payload.gameDate)
|
||||
});
|
||||
|
||||
return NextResponse.json(serialize(appearance), { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { pitchingRulesSchema } from "@/lib/validators";
|
||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const rules = await PitchingRuleModel.find().sort({ sortOrder: 1 }).lean();
|
||||
return NextResponse.json(serialize(rules));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = pitchingRulesSchema.parse(await request.json());
|
||||
|
||||
await PitchingRuleModel.deleteMany({});
|
||||
const rules = await PitchingRuleModel.insertMany(
|
||||
payload.map((rule, index) => ({
|
||||
...rule,
|
||||
sortOrder: index
|
||||
}))
|
||||
);
|
||||
|
||||
return NextResponse.json(serialize(rules));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { playerSchema } from "@/lib/validators";
|
||||
import { LineupModel } from "@/models/Lineup";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
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 = playerSchema.parse(await request.json());
|
||||
const player = await PlayerModel.findByIdAndUpdate(id, payload, { new: true, runValidators: true }).lean();
|
||||
|
||||
if (!player) {
|
||||
return NextResponse.json({ error: "Player not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(serialize(player));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { id } = await params;
|
||||
|
||||
await Promise.all([
|
||||
PitchingAppearanceModel.deleteMany({ playerId: id }),
|
||||
LineupModel.updateMany(
|
||||
{},
|
||||
{
|
||||
$pull: {
|
||||
playerIds: id,
|
||||
bench: id,
|
||||
battingOrder: { playerId: id },
|
||||
defensiveAssignments: { playerId: id }
|
||||
}
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
const player = await PlayerModel.findByIdAndDelete(id).lean();
|
||||
if (!player) {
|
||||
return NextResponse.json({ error: "Player not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { buildPitcherAvailabilityMap } from "@/lib/pitching";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { toDateOnly } from "@/lib/utils";
|
||||
import { playerSchema } from "@/lib/validators";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const gameDate = searchParams.get("gameDate") ?? toDateOnly(new Date());
|
||||
|
||||
const [players, rules, appearances] = await Promise.all([
|
||||
PlayerModel.find().sort({ jerseyNumber: 1, lastName: 1 }).lean(),
|
||||
PitchingRuleModel.find().sort({ sortOrder: 1 }).lean(),
|
||||
PitchingAppearanceModel.find().lean()
|
||||
]);
|
||||
|
||||
const availabilityMap = buildPitcherAvailabilityMap(
|
||||
players.map((player) => ({ ...player, _id: String(player._id) })),
|
||||
appearances.map((appearance) => ({ ...appearance, playerId: String(appearance.playerId) })),
|
||||
rules,
|
||||
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)],
|
||||
latestPitching: latestAppearanceMap[String(player._id)]
|
||||
}))
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = playerSchema.parse(await request.json());
|
||||
const player = await PlayerModel.create(payload);
|
||||
return NextResponse.json(serialize(player), { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DashboardClient } from "@/components/DashboardClient";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GameHistoryClient } from "@/components/GameHistoryClient";
|
||||
|
||||
export default function GameHistoryPage() {
|
||||
return <GameHistoryClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GameScoringClient } from "@/components/GameScoringClient";
|
||||
|
||||
export default function GamesPage() {
|
||||
return <GameScoringClient />;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 1 0 0;
|
||||
--foreground: 0.145 0 0;
|
||||
--card: 1 0 0;
|
||||
--card-foreground: 0.145 0 0;
|
||||
--popover: 1 0 0;
|
||||
--popover-foreground: 0.145 0 0;
|
||||
--primary: 0.205 0 0;
|
||||
--primary-foreground: 0.985 0 0;
|
||||
--secondary: 0.97 0 0;
|
||||
--secondary-foreground: 0.205 0 0;
|
||||
--muted: 0.97 0 0;
|
||||
--muted-foreground: 0.556 0 0;
|
||||
--accent: 0.97 0 0;
|
||||
--accent-foreground: 0.205 0 0;
|
||||
--destructive: 0.577 0.245 27.325;
|
||||
--destructive-foreground: 0.985 0 0;
|
||||
--border: 0.922 0 0;
|
||||
--input: 0.922 0 0;
|
||||
--ring: 0.708 0 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||
background-image: radial-gradient(circle at top, rgba(0, 0, 0, 0.035), transparent 32%);
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-inherit no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.app-surface {
|
||||
@apply rounded-3xl border bg-card shadow-panel;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
@apply mb-2 block text-sm font-medium text-muted-foreground;
|
||||
}
|
||||
|
||||
.field-select {
|
||||
@apply flex h-11 w-full rounded-2xl border border-input bg-background px-4 py-2 text-sm text-foreground shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Coach Connor's Lineup Lab",
|
||||
description: "Roster, scorekeeping, pitching availability, and lineup planning for game day."
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LineupClient } from "@/components/LineupClient";
|
||||
|
||||
export default function LineupPage() {
|
||||
return <LineupClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RosterClient } from "@/components/RosterClient";
|
||||
|
||||
export default function RosterPage() {
|
||||
return <RosterClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SettingsClient } from "@/components/SettingsClient";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return <SettingsClient />;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 },
|
||||
{ href: "/roster", label: "Roster", icon: Users },
|
||||
{ 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 }
|
||||
];
|
||||
|
||||
export function AppShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="container py-3 md:py-6">
|
||||
<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-x-0 top-0 h-24 bg-gradient-to-b from-white/10 to-transparent" />
|
||||
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<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" />
|
||||
Coach Connor's Lineup Lab
|
||||
</div>
|
||||
<h1 className="font-display text-3xl font-semibold leading-none tracking-tight md:text-5xl">{title}</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-white/70 md:text-base">{subtitle}</p>
|
||||
</div>
|
||||
{actions ? <div className="relative flex items-start">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-medium transition md:text-sm",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-panel"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<main className="mt-4 grid gap-4">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(nextOpen) => (!nextOpen ? onCancel() : undefined)}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-slate-950/50 backdrop-blur-sm" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-[1.75rem] border border-white/60 bg-card p-6 shadow-glow focus:outline-none">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<Dialog.Title className="font-display text-2xl font-semibold">{title}</Dialog.Title>
|
||||
<Dialog.Description className="mt-2 text-sm text-muted-foreground">{description}</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button className="rounded-full p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import type { DashboardResponse } from "@/lib/types";
|
||||
|
||||
export function DashboardClient() {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const visiblePitching = data?.recentPitching.filter((entry) => entry.status !== "unknown") ?? [];
|
||||
|
||||
async function loadDashboard() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchJson<DashboardResponse>("/api/dashboard");
|
||||
setData(response);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load dashboard.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Game Day Snapshot"
|
||||
subtitle="Check roster health, pitcher rest, and lineup readiness before first pitch."
|
||||
>
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
{loading ? (
|
||||
<SectionCard title="Loading" description="Pulling current roster and pitching data." />
|
||||
) : data && data.rosterCount > 0 ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{[
|
||||
{ label: "Rostered Players", value: data.rosterCount },
|
||||
{ label: "Active Today", value: data.activePlayers },
|
||||
{ label: "Pitchers Available", value: data.availablePitchers, color: "text-emerald-700" },
|
||||
{ label: "Need Rest", value: data.needsRest, color: "text-amber-700" }
|
||||
].map((item) => (
|
||||
<Card key={item.label} className="overflow-hidden">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">{item.label}</p>
|
||||
<p className={`mt-2 font-display text-5xl leading-none ${item.color ?? "text-foreground"}`}>
|
||||
{item.value}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SectionCard
|
||||
title="Pitcher Availability"
|
||||
description="Availability is based on the latest appearance and your configured rest rules."
|
||||
>
|
||||
{visiblePitching.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{visiblePitching.map((entry) => (
|
||||
<div
|
||||
key={entry.playerId}
|
||||
className="flex flex-col gap-3 py-4 md:flex-row md:items-start md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{entry.playerName}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{entry.lastPitchedDate
|
||||
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
|
||||
: "No pitching history recorded yet."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 md:items-end">
|
||||
<StatusBadge status={entry.status} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{entry.daysSinceLastAppearance === null
|
||||
? "No history"
|
||||
: `${entry.daysSinceLastAppearance} day(s) since last outing`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No eligible pitchers to show"
|
||||
description="Pitchers with unknown availability are hidden from this dashboard view."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</>
|
||||
) : (
|
||||
<SectionCard title="No Team Yet" description="Add players on the roster screen to start tracking availability.">
|
||||
<EmptyState
|
||||
title="Roster is empty"
|
||||
description="Add your team on the roster screen, then return here to review availability and game-day readiness."
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[1.75rem] border border-dashed border-border bg-muted/50 px-6 py-10 text-center">
|
||||
<h3 className="font-display text-2xl font-semibold">{title}</h3>
|
||||
<p className="mx-auto mt-2 max-w-xl text-sm text-muted-foreground">{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
"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 { getBattingSummary, getPitchingSummary, hasBattingStats, hasPitchingStats } from "@/lib/baseball-stats";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import type { GameRecord } from "@/lib/types";
|
||||
|
||||
const battingFields = [
|
||||
["atBats", "AB"],
|
||||
["runs", "R"],
|
||||
["hits", "H"],
|
||||
["doubles", "2B"],
|
||||
["triples", "3B"],
|
||||
["homeRuns", "HR"],
|
||||
["runsBattedIn", "RBI"],
|
||||
["walks", "BB"],
|
||||
["strikeouts", "SO"],
|
||||
["stolenBases", "SB"]
|
||||
] as const;
|
||||
|
||||
const pitchingFields = [
|
||||
["pitchCount", "Pitches"],
|
||||
["inningsPitchedOuts", "IP Outs"],
|
||||
["earnedRunsAllowed", "ER"],
|
||||
["hitsAllowed", "H Allowed"],
|
||||
["walksAllowed", "BB Allowed"],
|
||||
["strikeoutsPitched", "Pitching K"]
|
||||
] 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="grid gap-3">
|
||||
{game.playerStats.map((playerStat) => (
|
||||
<div key={playerStat.playerId} className="rounded-[1rem] border border-border bg-white/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold">{playerStat.playerName}</p>
|
||||
<p className="text-xs text-muted-foreground">Saved stat line</p>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Hitting Summary
|
||||
</p>
|
||||
{hasBattingStats(playerStat) ? (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
|
||||
{getBattingSummary(playerStat).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
|
||||
<span>{value ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No batting stats recorded.</p>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
{battingFields.map(([field, label]) => (
|
||||
<div key={label}>
|
||||
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
|
||||
<span>{playerStat[field] ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Pitching Summary
|
||||
</p>
|
||||
{hasPitchingStats(playerStat) ? (
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
|
||||
{getPitchingSummary(playerStat).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
|
||||
<span>{value ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No pitching stats recorded.</p>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
{pitchingFields.map(([field, label]) => (
|
||||
<div key={label}>
|
||||
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
|
||||
<span>{playerStat[field] ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, 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 type { GamePlayerStatsInput, GameRecord, LineupRecord } from "@/lib/types";
|
||||
|
||||
const battingFields = [
|
||||
["atBats", "AB"],
|
||||
["runs", "R"],
|
||||
["hits", "H"],
|
||||
["doubles", "2B"],
|
||||
["triples", "3B"],
|
||||
["homeRuns", "HR"],
|
||||
["runsBattedIn", "RBI"],
|
||||
["walks", "BB"],
|
||||
["strikeouts", "SO"],
|
||||
["stolenBases", "SB"]
|
||||
] as const;
|
||||
|
||||
const pitchingFields = [
|
||||
["pitchCount", "Pitches"],
|
||||
["inningsPitchedOuts", "IP Outs"],
|
||||
["earnedRunsAllowed", "ER"],
|
||||
["hitsAllowed", "H Allowed"],
|
||||
["walksAllowed", "BB Allowed"],
|
||||
["strikeoutsPitched", "Pitching K"]
|
||||
] as const;
|
||||
|
||||
function pickDefaultLineupId(gameDate: string, lineups: LineupRecord[]) {
|
||||
const exactDate = lineups.find((lineup) => lineup.gameDate === gameDate);
|
||||
return exactDate?._id ?? lineups[0]?._id ?? "";
|
||||
}
|
||||
|
||||
export function GameScoringClient() {
|
||||
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [lineups, setLineups] = useState<LineupRecord[]>([]);
|
||||
const [selectedLineupId, setSelectedLineupId] = useState("");
|
||||
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);
|
||||
|
||||
async function loadLineups() {
|
||||
try {
|
||||
const response = await fetchJson<LineupRecord[]>("/api/lineups");
|
||||
setLineups(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.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadLineups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedLineupId((current) => {
|
||||
if (current && lineups.some((lineup) => lineup._id === current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return pickDefaultLineupId(gameDate, lineups);
|
||||
});
|
||||
}, [gameDate, lineups]);
|
||||
|
||||
const selectedLineup = useMemo(
|
||||
() => lineups.find((lineup) => lineup._id === selectedLineupId) ?? null,
|
||||
[lineups, selectedLineupId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLineup) {
|
||||
setPlayerStats({});
|
||||
setIsScoring(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPlayerStats((current) =>
|
||||
Object.fromEntries(
|
||||
selectedLineup.battingOrder.map((slot) => [
|
||||
slot.playerId,
|
||||
current[slot.playerId] ?? {
|
||||
playerId: slot.playerId,
|
||||
playerName: slot.playerName
|
||||
}
|
||||
])
|
||||
)
|
||||
);
|
||||
}, [selectedLineup]);
|
||||
|
||||
function updatePlayerStat(
|
||||
playerId: string,
|
||||
playerName: string,
|
||||
field: keyof Omit<GamePlayerStatsInput, "playerId" | "playerName">,
|
||||
value: string
|
||||
) {
|
||||
setPlayerStats((current) => ({
|
||||
...current,
|
||||
[playerId]: {
|
||||
...(current[playerId] ?? { playerId, playerName }),
|
||||
playerId,
|
||||
playerName,
|
||||
[field]: value === "" ? null : Number(value)
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveGame() {
|
||||
if (!selectedLineup || !selectedLineup._id) {
|
||||
setError("Select a saved lineup before scoring the game.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
gameDate,
|
||||
lineupId: selectedLineup._id,
|
||||
opponent,
|
||||
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 }
|
||||
)
|
||||
};
|
||||
|
||||
const saved = await fetchJson<GameRecord>("/api/games", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
setMessage(`Saved game #${saved.gameNumber} for ${saved.gameDate}.`);
|
||||
setOpponent("");
|
||||
setTeamScore("");
|
||||
setOpponentScore("");
|
||||
setNotes("");
|
||||
setPlayerStats({});
|
||||
setIsScoring(false);
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to save game.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Score Game"
|
||||
subtitle="Choose the lineup used, then open the scorecard only when you're ready to enter the game results."
|
||||
>
|
||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard
|
||||
title="Game Setup"
|
||||
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);
|
||||
setIsScoring(false);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Lineup Used</span>
|
||||
<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}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Opponent</span>
|
||||
<Input value={opponent} onChange={(event) => setOpponent(event.target.value)} placeholder="Optional" />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<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
|
||||
title="No lineup available"
|
||||
description="Save a lineup on the lineup tab first, then return here to score the game."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{selectedLineup && isScoring ? (
|
||||
<SectionCard
|
||||
title="Player Results"
|
||||
description="Enter player results as the game unfolds. Final score lives here because you know it once the game is actually being scored."
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 rounded-[1.5rem] border border-border bg-muted/30 p-4 md:max-w-sm">
|
||||
<label>
|
||||
<span className="field-label">Our Score</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={teamScore}
|
||||
onChange={(event) => setTeamScore(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Their Score</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={opponentScore}
|
||||
onChange={(event) => setOpponentScore(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{selectedLineup.battingOrder.map((slot, index) => {
|
||||
const statLine = playerStats[slot.playerId] ?? {
|
||||
playerId: slot.playerId,
|
||||
playerName: slot.playerName
|
||||
};
|
||||
|
||||
return (
|
||||
<Card key={slot.playerId}>
|
||||
<CardContent className="grid gap-3 pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{slot.playerName}</h3>
|
||||
<p className="text-xs text-muted-foreground">Batting order slot {index + 1}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Batting Line
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5 lg:grid-cols-6">
|
||||
{battingFields.map(([field, label]) => (
|
||||
<label key={field}>
|
||||
<span className="field-label">{label}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={statLine[field] ?? ""}
|
||||
onChange={(event) =>
|
||||
updatePlayerStat(slot.playerId, slot.playerName, field, event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Pitching Line
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{pitchingFields.map(([field, label]) => (
|
||||
<label key={field}>
|
||||
<span className="field-label">{label}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={statLine[field] ?? ""}
|
||||
onChange={(event) =>
|
||||
updatePlayerStat(slot.playerId, slot.playerName, field, event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={() => void saveGame()}>Save Game</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
"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 { AppShell } from "@/components/AppShell";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
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 { fetchJson } from "@/lib/client";
|
||||
import { FIELD_POSITIONS } from "@/lib/constants";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
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() {
|
||||
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
||||
const [selectedPlayerIds, setSelectedPlayerIds] = useState<string[]>([]);
|
||||
const [startingPitcherId, setStartingPitcherId] = useState<string>("");
|
||||
const [showAllPitchers, setShowAllPitchers] = useState(false);
|
||||
const [generated, setGenerated] = useState<GeneratedLineup | null>(null);
|
||||
const [savedLineups, setSavedLineups] = useState<LineupRecord[]>([]);
|
||||
const [lineupPendingDelete, setLineupPendingDelete] = useState<LineupRecord | null>(null);
|
||||
const [message, setMessage] = 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) {
|
||||
try {
|
||||
const response = await fetchJson<PlayerRecord[]>(`/api/players?gameDate=${targetDate}`);
|
||||
setPlayers(response);
|
||||
const activeIds = response.filter((player) => player.isActive).map((player) => player._id);
|
||||
setSelectedPlayerIds(activeIds);
|
||||
const firstEligible = response.find((player) => player.pitcherAvailability.status === "available");
|
||||
setStartingPitcherId(firstEligible?._id ?? response[0]?._id ?? "");
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load players.");
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
void loadPlayers(gameDate);
|
||||
void loadSavedLineups(gameDate);
|
||||
}, [gameDate]);
|
||||
|
||||
const selectablePitchers = useMemo(
|
||||
() =>
|
||||
players.filter(
|
||||
(player) =>
|
||||
selectedPlayerIds.includes(player._id) &&
|
||||
(showAllPitchers || player.pitcherAvailability.status !== "needs-rest")
|
||||
),
|
||||
[players, selectedPlayerIds, showAllPitchers]
|
||||
);
|
||||
|
||||
const selectedPlayers = useMemo(
|
||||
() => players.filter((player) => selectedPlayerIds.includes(player._id)),
|
||||
[players, selectedPlayerIds]
|
||||
);
|
||||
|
||||
function updateGenerated(next: GeneratedLineup | null) {
|
||||
setGenerated(next);
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
gameDate,
|
||||
playerIds: selectedPlayerIds,
|
||||
startingPitcherId: startingPitcherId || null
|
||||
})
|
||||
});
|
||||
setGenerated(response);
|
||||
setMessage("Suggested lineup generated.");
|
||||
} catch (generateError) {
|
||||
setError(generateError instanceof Error ? generateError.message : "Unable to generate lineup.");
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!generated) return;
|
||||
|
||||
try {
|
||||
const saved = await fetchJson<LineupRecord>("/api/lineups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...generated,
|
||||
playerIds: selectedPlayerIds
|
||||
})
|
||||
});
|
||||
setMessage(`Saved as lineup #${saved.lineupNumber} for ${saved.gameDate}.`);
|
||||
await loadSavedLineups(gameDate);
|
||||
} catch (saveError) {
|
||||
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 (
|
||||
<AppShell
|
||||
title="Generate Lineup"
|
||||
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}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard title="Game Setup" description="Keep the setup compact for quick game-day edits.">
|
||||
<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)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Starting Pitcher</span>
|
||||
<select
|
||||
className="field-select"
|
||||
value={startingPitcherId}
|
||||
onChange={(event) => setStartingPitcherId(event.target.value)}
|
||||
>
|
||||
{selectablePitchers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="md:col-span-2 flex items-center gap-3 rounded-2xl border border-border bg-white/70 px-4 py-3 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAllPitchers}
|
||||
onChange={(event) => setShowAllPitchers(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
Show pitchers who need rest
|
||||
</label>
|
||||
<div className="md:col-span-2 flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
||||
Generate
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={!generated} onClick={() => void save()}>
|
||||
Save Version
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{players.map((player) => {
|
||||
const selected = selectedPlayerIds.includes(player._id);
|
||||
return (
|
||||
<Card
|
||||
key={player._id}
|
||||
className={selected ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20" : ""}
|
||||
>
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||
<label className="flex flex-1 cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(event) =>
|
||||
setSelectedPlayerIds((current) =>
|
||||
event.target.checked
|
||||
? [...current, player._id]
|
||||
: current.filter((id) => id !== player._id)
|
||||
)
|
||||
}
|
||||
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-base font-semibold">
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
#{player.jerseyNumber} {player.primaryPosition}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<StatusBadge status={player.pitcherAvailability.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{generated ? (
|
||||
<>
|
||||
<SectionCard title="Batting Order" description="Drag rows to reorder.">
|
||||
<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) => (
|
||||
<SortableBattingOrderItem
|
||||
key={slot.playerId}
|
||||
slot={slot}
|
||||
index={index}
|
||||
selectedPlayers={selectedPlayers}
|
||||
onChange={(playerId) => {
|
||||
const nextPlayer = selectedPlayers.find((player) => player._id === playerId);
|
||||
if (!nextPlayer || !generated) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateBattingOrder(
|
||||
generated.battingOrder.map((entry, entryIndex) =>
|
||||
entryIndex === index
|
||||
? {
|
||||
...entry,
|
||||
playerId: nextPlayer._id,
|
||||
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName)
|
||||
}
|
||||
: entry
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Defensive Alignment" description="Keep the fielding assignments editable.">
|
||||
<div className="divide-y divide-border">
|
||||
{FIELD_POSITIONS.map((position) => {
|
||||
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
||||
return (
|
||||
<div key={position} className="grid gap-3 py-3 md:grid-cols-[110px_1fr] md:items-center">
|
||||
<p className="text-sm font-semibold">{position}</p>
|
||||
<select
|
||||
className="field-select"
|
||||
value={assignment?.playerId ?? ""}
|
||||
onChange={(event) => {
|
||||
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
||||
if (!nextPlayer || !generated) return;
|
||||
const nextAssignments = generated.defensiveAssignments.filter((entry) => entry.position !== position);
|
||||
nextAssignments.push({
|
||||
playerId: nextPlayer._id,
|
||||
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
|
||||
position
|
||||
});
|
||||
updateGenerated({
|
||||
...generated,
|
||||
defensiveAssignments: nextAssignments
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{selectedPlayers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{generated.notes.length > 0 ? (
|
||||
<div className="rounded-[1.5rem] border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
{generated.notes.map((note) => (
|
||||
<p key={note}>{note}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
</>
|
||||
) : (
|
||||
<SectionCard title="No Lineup Yet" description="Generate a lineup once your game-day roster is selected.">
|
||||
<EmptyState
|
||||
title="Waiting on lineup"
|
||||
description="Select active players, choose a starter, then generate the first version."
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { PlayerInput, PlayerRecord } from "@/lib/types";
|
||||
|
||||
const defaultForm: PlayerInput = {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
jerseyNumber: 0,
|
||||
batsThrows: "R/R",
|
||||
primaryPosition: "P",
|
||||
secondaryPosition: "C",
|
||||
tertiaryPosition: "1B",
|
||||
isActive: true,
|
||||
notes: "",
|
||||
stats: {}
|
||||
};
|
||||
|
||||
export function emptyPlayerForm() {
|
||||
return { ...defaultForm };
|
||||
}
|
||||
|
||||
export function toPlayerForm(player: PlayerRecord): PlayerInput {
|
||||
return {
|
||||
firstName: player.firstName,
|
||||
lastName: player.lastName,
|
||||
jerseyNumber: player.jerseyNumber,
|
||||
batsThrows: player.batsThrows,
|
||||
primaryPosition: player.primaryPosition,
|
||||
secondaryPosition: player.secondaryPosition ?? null,
|
||||
tertiaryPosition: player.tertiaryPosition ?? null,
|
||||
isActive: player.isActive,
|
||||
notes: player.notes ?? "",
|
||||
stats: player.stats ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
const statFields = [
|
||||
["gamesPlayed", "Games"],
|
||||
["atBats", "AB"],
|
||||
["runs", "Runs"],
|
||||
["hits", "Hits"],
|
||||
["doubles", "2B"],
|
||||
["triples", "3B"],
|
||||
["homeRuns", "HR"],
|
||||
["runsBattedIn", "RBI"],
|
||||
["walks", "BB"],
|
||||
["strikeouts", "SO"],
|
||||
["stolenBases", "SB"]
|
||||
] as const;
|
||||
|
||||
const pitchingStatFields = [
|
||||
["inningsPitchedOuts", "IP Outs"],
|
||||
["earnedRunsAllowed", "ER"],
|
||||
["hitsAllowed", "H Allowed"],
|
||||
["walksAllowed", "BB Allowed"],
|
||||
["strikeoutsPitched", "Pitching K"]
|
||||
] as const;
|
||||
|
||||
export function PlayerForm({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
submitLabel
|
||||
}: {
|
||||
value: PlayerInput;
|
||||
onChange: (next: PlayerInput) => void;
|
||||
onSubmit: () => void;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className="field-label">First Name</span>
|
||||
<Input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Last Name</span>
|
||||
<Input value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Jersey #</span>
|
||||
<Input type="number" value={value.jerseyNumber} onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Bats / Throws</span>
|
||||
<select className="field-select" value={value.batsThrows} onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}>
|
||||
{HANDEDNESS_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Primary Position</span>
|
||||
<select className="field-select" value={value.primaryPosition} onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })}>
|
||||
{POSITIONS.map((position) => (
|
||||
<option key={position} value={position}>
|
||||
{position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Secondary Position</span>
|
||||
<select
|
||||
className="field-select"
|
||||
value={value.secondaryPosition ?? ""}
|
||||
onChange={(event) => onChange({ ...value, secondaryPosition: event.target.value ? (event.target.value as PlayerInput["secondaryPosition"]) : null })}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{POSITIONS.map((position) => (
|
||||
<option key={position} value={position}>
|
||||
{position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Tertiary Position</span>
|
||||
<select
|
||||
className="field-select"
|
||||
value={value.tertiaryPosition ?? ""}
|
||||
onChange={(event) => onChange({ ...value, tertiaryPosition: event.target.value ? (event.target.value as PlayerInput["tertiaryPosition"]) : null })}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{POSITIONS.map((position) => (
|
||||
<option key={position} value={position}>
|
||||
{position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-border bg-white/70 px-4 py-3 text-sm font-medium">
|
||||
<input type="checkbox" checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} className="h-4 w-4 rounded border-border text-primary focus:ring-primary" />
|
||||
Active for selection
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className="field-label">Notes</span>
|
||||
<Textarea rows={4} value={value.notes ?? ""} onChange={(event) => onChange({ ...value, notes: event.target.value })} />
|
||||
</label>
|
||||
<div className="md:col-span-2 space-y-4 rounded-[1.5rem] border border-border bg-muted/40 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Optional Player Stats</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Leave any field blank if you do not want to track it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Batting Totals</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{statFields.map(([key, label]) => (
|
||||
<label key={key}>
|
||||
<span className="field-label">{label}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.stats?.[key] ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...value,
|
||||
stats: {
|
||||
...value.stats,
|
||||
[key]: event.target.value === "" ? null : Number(event.target.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Pitching Totals</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{pitchingStatFields.map(([key, label]) => (
|
||||
<label key={key}>
|
||||
<span className="field-label">{label}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.stats?.[key] ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...value,
|
||||
stats: {
|
||||
...value.stats,
|
||||
[key]: event.target.value === "" ? null : Number(event.target.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Button onClick={onSubmit}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { PlayerForm, emptyPlayerForm, toPlayerForm } from "@/components/PlayerForm";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
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 { getBattingSummary, getPitchingSummary, hasBattingStats, hasPitchingStats } from "@/lib/baseball-stats";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
import type { PlayerInput, PlayerPitchingInput, PlayerRecord } from "@/lib/types";
|
||||
|
||||
export function RosterClient() {
|
||||
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null);
|
||||
const [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 editingPlayer = useMemo(
|
||||
() => players.find((player) => player._id === editingPlayerId) ?? null,
|
||||
[editingPlayerId, players]
|
||||
);
|
||||
|
||||
async function loadPlayers() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetchJson<PlayerRecord[]>("/api/players");
|
||||
setPlayers(response);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load roster.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlayers();
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingPlayerId) {
|
||||
setShowPlayerForm(true);
|
||||
playerFormRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, [editingPlayerId]);
|
||||
|
||||
function startEditingPlayer(playerId: string) {
|
||||
setEditingPlayerId(playerId);
|
||||
setShowPlayerForm(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function stopEditingPlayer() {
|
||||
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) {
|
||||
requestAnimationFrame(() => {
|
||||
playerFormRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function submitPlayer() {
|
||||
try {
|
||||
const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players";
|
||||
const method = editingPlayerId ? "PATCH" : "POST";
|
||||
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) {
|
||||
setError(submitError instanceof Error ? submitError.message : "Unable to save player.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlayer(player: PlayerRecord) {
|
||||
try {
|
||||
await fetchJson(`/api/players/${player._id}`, { method: "DELETE" });
|
||||
setMessage("Player removed.");
|
||||
setPlayerPendingDelete(null);
|
||||
await loadPlayers();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete player.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Roster Management"
|
||||
subtitle="Review the active roster first, then open a compact add or edit form when you need it."
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={playerPendingDelete !== null}
|
||||
title="Remove Player?"
|
||||
description={
|
||||
playerPendingDelete
|
||||
? `Remove ${formatPlayerName(playerPendingDelete.firstName, playerPendingDelete.lastName)} from the roster? This also removes their pitching history and saved lineup references.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Remove Player"
|
||||
onCancel={() => setPlayerPendingDelete(null)}
|
||||
onConfirm={() => (playerPendingDelete ? void deletePlayer(playerPendingDelete) : undefined)}
|
||||
/>
|
||||
|
||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard
|
||||
title="Current Roster"
|
||||
description="Tap edit for fast changes and check pitcher status at a glance."
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={startAddingPlayer}>
|
||||
{showPlayerForm && !editingPlayerId ? "Hide Add Player" : "Add Player"}
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading roster...</p>
|
||||
) : players.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-[1.5rem] border border-border bg-white/70">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="border-b border-border bg-muted/60 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3 font-semibold">#</th>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{players.map((player) => (
|
||||
<tr
|
||||
key={player._id}
|
||||
className={
|
||||
editingPlayerId === player._id ? "bg-secondary/5" : ""
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-3 align-top font-medium">{player.jerseyNumber}</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-medium">{formatPlayerName(player.firstName, player.lastName)}</div>
|
||||
{player.notes ? (
|
||||
<div className="mt-1 max-w-[18rem] text-xs text-muted-foreground">{player.notes}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-muted-foreground">
|
||||
<div>{player.primaryPosition}</div>
|
||||
{player.secondaryPosition ? <div>{player.secondaryPosition}</div> : null}
|
||||
{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">
|
||||
{hasBattingStats(player.stats) || hasPitchingStats(player.stats) ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
{hasBattingStats(player.stats) ? (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-muted-foreground">Hitting</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{getBattingSummary(player.stats).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
|
||||
<span>{value ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasPitchingStats(player.stats) ? (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-muted-foreground">Pitching</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{getPitchingSummary(player.stats).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
|
||||
<span>{value ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</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}`
|
||||
: "No history"}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="flex flex-col gap-2">
|
||||
<StatusBadge status={player.pitcherAvailability.status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{player.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => startEditingPlayer(player._id)}>
|
||||
{editingPlayerId === player._id ? "Editing" : "Edit"}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No players yet"
|
||||
description="Add your first player above to start building the roster."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showPlayerForm || editingPlayerId ? (
|
||||
<div ref={playerFormRef}>
|
||||
<SectionCard
|
||||
title={editingPlayerId ? "Edit Player" : "Add Player"}
|
||||
description="Use the compact form below to update the roster."
|
||||
>
|
||||
{editingPlayer ? (
|
||||
<Alert tone="info" className="flex items-center justify-between gap-4">
|
||||
<span>Editing {formatPlayerName(editingPlayer.firstName, editingPlayer.lastName)}.</span>
|
||||
<Button variant="ghost" size="sm" onClick={stopEditingPlayer}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Alert>
|
||||
) : null}
|
||||
<PlayerForm
|
||||
value={playerForm}
|
||||
onChange={setPlayerForm}
|
||||
onSubmit={() => void submitPlayer()}
|
||||
submitLabel={editingPlayerId ? "Save Changes" : "Add Player"}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pb-3 pt-4 md:px-6 md:pb-4 md:pt-6">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-4 md:space-y-5 md:px-6 md:pb-6">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { DEFAULT_PITCHING_RULES } from "@/lib/constants";
|
||||
import type { PitchingRuleRecord } from "@/lib/types";
|
||||
|
||||
export function SettingsClient() {
|
||||
const [rules, setRules] = useState<PitchingRuleRecord[]>(DEFAULT_PITCHING_RULES);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
async function loadRules() {
|
||||
try {
|
||||
const response = await fetchJson<PitchingRuleRecord[]>("/api/pitching-rules");
|
||||
if (response.length > 0) {
|
||||
setRules(
|
||||
response.map((rule) => ({
|
||||
minPitches: rule.minPitches,
|
||||
maxPitches: rule.maxPitches,
|
||||
requiredRestDays: rule.requiredRestDays
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load rules.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRules();
|
||||
}, []);
|
||||
|
||||
async function saveRules() {
|
||||
try {
|
||||
await fetchJson("/api/pitching-rules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(rules)
|
||||
});
|
||||
setMessage("Pitching rules updated.");
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Failed to save rules.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Pitching Rest Rules"
|
||||
subtitle="Tune the rest windows your league uses so eligibility stays accurate and easy to trust."
|
||||
>
|
||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard title="Rule Table" description="Each row maps a pitch-count range to required rest days.">
|
||||
<div className="overflow-hidden rounded-[1.75rem] border border-border bg-white/70">
|
||||
<div className="grid grid-cols-3 gap-4 border-b border-border bg-muted/60 px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<span>Min Pitches</span>
|
||||
<span>Max Pitches</span>
|
||||
<span>Rest Days</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{rules.map((rule, index) => (
|
||||
<div key={`${rule.minPitches}-${index}`} className="grid grid-cols-3 gap-4 px-4 py-4">
|
||||
<Input
|
||||
type="number"
|
||||
value={rule.minPitches}
|
||||
onChange={(event) =>
|
||||
setRules((current) =>
|
||||
current.map((entry, entryIndex) =>
|
||||
entryIndex === index ? { ...entry, minPitches: Number(event.target.value) } : entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={rule.maxPitches ?? ""}
|
||||
placeholder="No max"
|
||||
onChange={(event) =>
|
||||
setRules((current) =>
|
||||
current.map((entry, entryIndex) =>
|
||||
entryIndex === index
|
||||
? { ...entry, maxPitches: event.target.value ? Number(event.target.value) : null }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={rule.requiredRestDays}
|
||||
onChange={(event) =>
|
||||
setRules((current) =>
|
||||
current.map((entry, entryIndex) =>
|
||||
entryIndex === index
|
||||
? { ...entry, requiredRestDays: Number(event.target.value) }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button onClick={() => void saveRules()}>Save Rules</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PitcherAvailability } from "@/lib/types";
|
||||
import { cn, statusLabel } from "@/lib/utils";
|
||||
|
||||
export function StatusBadge({ status }: { status: PitcherAvailability }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full px-3 py-1 text-xs font-semibold",
|
||||
status === "available" && "bg-emerald-100 text-emerald-800",
|
||||
status === "needs-rest" && "bg-red-100 text-red-800",
|
||||
status === "unknown" && "bg-slate-100 text-slate-700"
|
||||
)}
|
||||
>
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Alert({
|
||||
className,
|
||||
tone = "default",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { tone?: "default" | "success" | "error" | "info" }) {
|
||||
const toneClasses =
|
||||
tone === "success"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-900"
|
||||
: tone === "error"
|
||||
? "border-red-200 bg-red-50 text-red-900"
|
||||
: tone === "info"
|
||||
? "border-sky-200 bg-sky-50 text-sky-900"
|
||||
: "border-border bg-card text-foreground";
|
||||
|
||||
return <div className={cn("rounded-2xl border px-4 py-3 text-sm shadow-sm", toneClasses, className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-panel hover:bg-primary/90",
|
||||
secondary: "bg-card text-foreground border border-border hover:bg-muted",
|
||||
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-panel hover:bg-destructive/90"
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-5",
|
||||
sm: "h-9 px-4 text-xs",
|
||||
lg: "h-12 px-6",
|
||||
icon: "h-10 w-10 rounded-full"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||
)
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-3xl border border-white/60 bg-card/90 text-card-foreground shadow-panel backdrop-blur",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1.5 p-4 md:p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("font-display text-2xl font-semibold tracking-tight", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("px-4 pb-4 md:px-6 md:pb-6", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-2xl border border-input bg-white/90 px-4 py-2 text-sm text-foreground shadow-sm transition placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"min-h-[120px] w-full rounded-2xl border border-input bg-white/90 px-4 py-3 text-sm text-foreground shadow-sm transition placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface UserContext {
|
||||
userId: string | null;
|
||||
role: "coach" | "admin" | "viewer";
|
||||
}
|
||||
|
||||
export async function getCurrentUserContext(): Promise<UserContext> {
|
||||
// Placeholder for future auth integration.
|
||||
return {
|
||||
userId: null,
|
||||
role: "admin"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { PlayerStatsInput } from "@/lib/types";
|
||||
|
||||
type StatValue = number | null | undefined;
|
||||
|
||||
function stat(value: StatValue) {
|
||||
return value ?? 0;
|
||||
}
|
||||
|
||||
function hasValue(value: StatValue) {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
function formatRate(value: number | null, digits = 3) {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.toFixed(digits).replace(/^0(?=\.)/, "");
|
||||
}
|
||||
|
||||
function formatDecimal(value: number | null, digits = 2) {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
export function formatInningsPitched(outs: StatValue) {
|
||||
const totalOuts = stat(outs);
|
||||
if (totalOuts <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wholeInnings = Math.floor(totalOuts / 3);
|
||||
const remainder = totalOuts % 3;
|
||||
return `${wholeInnings}.${remainder}`;
|
||||
}
|
||||
|
||||
export function hasBattingStats(stats?: PlayerStatsInput | null) {
|
||||
if (!stats) return false;
|
||||
|
||||
return [
|
||||
stats.atBats,
|
||||
stats.runs,
|
||||
stats.hits,
|
||||
stats.doubles,
|
||||
stats.triples,
|
||||
stats.homeRuns,
|
||||
stats.runsBattedIn,
|
||||
stats.walks,
|
||||
stats.strikeouts,
|
||||
stats.stolenBases
|
||||
].some(hasValue);
|
||||
}
|
||||
|
||||
export function hasPitchingStats(stats?: PlayerStatsInput | null) {
|
||||
if (!stats) return false;
|
||||
|
||||
return [
|
||||
stats.inningsPitchedOuts,
|
||||
stats.earnedRunsAllowed,
|
||||
stats.hitsAllowed,
|
||||
stats.walksAllowed,
|
||||
stats.strikeoutsPitched
|
||||
].some(hasValue);
|
||||
}
|
||||
|
||||
export function getBattingSummary(stats?: PlayerStatsInput | null) {
|
||||
if (!hasBattingStats(stats)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const atBats = stat(stats?.atBats);
|
||||
const hits = stat(stats?.hits);
|
||||
const walks = stat(stats?.walks);
|
||||
const doubles = stat(stats?.doubles);
|
||||
const triples = stat(stats?.triples);
|
||||
const homeRuns = stat(stats?.homeRuns);
|
||||
const singles = Math.max(hits - doubles - triples - homeRuns, 0);
|
||||
const totalBases = singles + doubles * 2 + triples * 3 + homeRuns * 4;
|
||||
|
||||
const average = atBats > 0 ? hits / atBats : null;
|
||||
const onBase = atBats + walks > 0 ? (hits + walks) / (atBats + walks) : null;
|
||||
const slugging = atBats > 0 ? totalBases / atBats : null;
|
||||
const ops = onBase !== null || slugging !== null ? (onBase ?? 0) + (slugging ?? 0) : null;
|
||||
|
||||
return [
|
||||
["AVG", formatRate(average)],
|
||||
["OBP", formatRate(onBase)],
|
||||
["SLG", formatRate(slugging)],
|
||||
["OPS", formatRate(ops)]
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function getPitchingSummary(stats?: PlayerStatsInput | null) {
|
||||
if (!hasPitchingStats(stats)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const outs = stat(stats?.inningsPitchedOuts);
|
||||
const innings = outs / 3;
|
||||
const earnedRuns = stat(stats?.earnedRunsAllowed);
|
||||
const hitsAllowed = stat(stats?.hitsAllowed);
|
||||
const walksAllowed = stat(stats?.walksAllowed);
|
||||
const strikeoutsPitched = stat(stats?.strikeoutsPitched);
|
||||
|
||||
const era = outs > 0 ? (earnedRuns * 27) / outs : null;
|
||||
const whip = innings > 0 ? (hitsAllowed + walksAllowed) / innings : null;
|
||||
|
||||
return [
|
||||
["IP", formatInningsPitched(outs)],
|
||||
["ERA", formatDecimal(era)],
|
||||
["WHIP", formatDecimal(whip)],
|
||||
["K", strikeoutsPitched > 0 || hasValue(stats?.strikeoutsPitched) ? String(strikeoutsPitched) : null]
|
||||
] as const;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
},
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(data?.error ?? "Request failed.");
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Handedness, PitchingRuleInput, Position } from "@/lib/types";
|
||||
|
||||
export const POSITIONS = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "UTIL"] as const satisfies readonly Position[];
|
||||
|
||||
export const FIELD_POSITIONS = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"] as const satisfies readonly Position[];
|
||||
|
||||
export const DEFAULT_PITCHING_RULES: PitchingRuleInput[] = [
|
||||
{ minPitches: 1, maxPitches: 20, requiredRestDays: 0 },
|
||||
{ minPitches: 21, maxPitches: 35, requiredRestDays: 1 },
|
||||
{ minPitches: 36, maxPitches: 50, requiredRestDays: 2 },
|
||||
{ minPitches: 51, maxPitches: 65, requiredRestDays: 3 },
|
||||
{ minPitches: 66, maxPitches: null, requiredRestDays: 4 }
|
||||
];
|
||||
|
||||
export const HANDEDNESS_OPTIONS = ["R/R", "R/L", "L/L", "L/R", "Switch"] as const satisfies readonly Handedness[];
|
||||
@@ -0,0 +1,35 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var mongooseCache:
|
||||
| {
|
||||
conn: typeof mongoose | null;
|
||||
promise: Promise<typeof mongoose> | null;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
const cache = global.mongooseCache ?? { conn: null, promise: null };
|
||||
global.mongooseCache = cache;
|
||||
|
||||
export async function connectToDatabase() {
|
||||
if (!MONGODB_URI) {
|
||||
throw new Error("Missing MONGODB_URI environment variable.");
|
||||
}
|
||||
|
||||
if (cache.conn) {
|
||||
return cache.conn;
|
||||
}
|
||||
|
||||
if (!cache.promise) {
|
||||
cache.promise = mongoose.connect(MONGODB_URI, {
|
||||
bufferCommands: false
|
||||
});
|
||||
}
|
||||
|
||||
cache.conn = await cache.promise;
|
||||
return cache.conn;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { FIELD_POSITIONS } from "@/lib/constants";
|
||||
import { buildPitcherAvailabilityMap } from "@/lib/pitching";
|
||||
import type { GeneratedLineup, Position } from "@/lib/types";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
|
||||
interface PlayerLike {
|
||||
_id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jerseyNumber: number;
|
||||
isActive: boolean;
|
||||
primaryPosition: Position;
|
||||
secondaryPosition?: Position | null;
|
||||
tertiaryPosition?: Position | null;
|
||||
}
|
||||
|
||||
interface AppearanceLike {
|
||||
playerId: string;
|
||||
gameDate: Date | string;
|
||||
pitchCount: number;
|
||||
}
|
||||
|
||||
interface RuleLike {
|
||||
minPitches: number;
|
||||
maxPitches: number | null;
|
||||
requiredRestDays: number;
|
||||
}
|
||||
|
||||
function positionScore(player: PlayerLike, position: Position) {
|
||||
if (player.primaryPosition === position) return 100;
|
||||
if (player.secondaryPosition === position) return 75;
|
||||
if (player.tertiaryPosition === position) return 50;
|
||||
if (player.primaryPosition === "UTIL") return 25;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function generateLineup({
|
||||
players,
|
||||
appearances,
|
||||
rules,
|
||||
gameDate,
|
||||
startingPitcherId
|
||||
}: {
|
||||
players: PlayerLike[];
|
||||
appearances: AppearanceLike[];
|
||||
rules: RuleLike[];
|
||||
gameDate: string;
|
||||
startingPitcherId?: string | null;
|
||||
}): GeneratedLineup {
|
||||
const notes: string[] = [];
|
||||
const availabilityMap = buildPitcherAvailabilityMap(players, appearances, rules, gameDate);
|
||||
const availablePlayers = players.filter((player) => player.isActive);
|
||||
const lineupPool = availablePlayers.length > 0 ? availablePlayers : players;
|
||||
const selectedPitcher =
|
||||
startingPitcherId && lineupPool.find((player) => player._id === startingPitcherId)
|
||||
? lineupPool.find((player) => player._id === startingPitcherId) ?? null
|
||||
: null;
|
||||
|
||||
if (selectedPitcher && availabilityMap[selectedPitcher._id]?.status === "needs-rest") {
|
||||
notes.push(`${formatPlayerName(selectedPitcher.firstName, selectedPitcher.lastName)} is currently marked as needing rest.`);
|
||||
}
|
||||
|
||||
const remainingPlayers = lineupPool.filter((player) => player._id !== selectedPitcher?._id);
|
||||
const defensiveAssignments: GeneratedLineup["defensiveAssignments"] = [];
|
||||
const assigned = new Set<string>();
|
||||
|
||||
if (selectedPitcher) {
|
||||
defensiveAssignments.push({
|
||||
playerId: selectedPitcher._id,
|
||||
playerName: formatPlayerName(selectedPitcher.firstName, selectedPitcher.lastName),
|
||||
position: "P"
|
||||
});
|
||||
assigned.add(selectedPitcher._id);
|
||||
}
|
||||
|
||||
for (const position of FIELD_POSITIONS.filter((entry) => entry !== "P")) {
|
||||
const candidate = remainingPlayers
|
||||
.filter((player) => !assigned.has(player._id))
|
||||
.sort((a, b) => positionScore(b, position) - positionScore(a, position))[0];
|
||||
|
||||
if (!candidate) {
|
||||
break;
|
||||
}
|
||||
|
||||
defensiveAssignments.push({
|
||||
playerId: candidate._id,
|
||||
playerName: formatPlayerName(candidate.firstName, candidate.lastName),
|
||||
position
|
||||
});
|
||||
assigned.add(candidate._id);
|
||||
}
|
||||
|
||||
const battingPool = [...lineupPool].sort((a, b) => {
|
||||
const aFlex = [a.primaryPosition, a.secondaryPosition, a.tertiaryPosition].filter(Boolean).length;
|
||||
const bFlex = [b.primaryPosition, b.secondaryPosition, b.tertiaryPosition].filter(Boolean).length;
|
||||
if (bFlex !== aFlex) return bFlex - aFlex;
|
||||
return a.jerseyNumber - b.jerseyNumber;
|
||||
});
|
||||
|
||||
const battingOrder = battingPool.map((player, index) => ({
|
||||
playerId: player._id,
|
||||
playerName: formatPlayerName(player.firstName, player.lastName),
|
||||
slot: index + 1
|
||||
}));
|
||||
|
||||
const bench = lineupPool.filter((player) => !assigned.has(player._id)).map((player) => player._id);
|
||||
|
||||
if (!selectedPitcher) {
|
||||
notes.push("No starting pitcher was selected. Choose one before game time.");
|
||||
}
|
||||
|
||||
if (lineupPool.length < FIELD_POSITIONS.length) {
|
||||
notes.push("Fewer than nine players are available. Some positions will need to be covered creatively.");
|
||||
}
|
||||
|
||||
return {
|
||||
gameDate,
|
||||
startingPitcherId: selectedPitcher?._id ?? null,
|
||||
battingOrder,
|
||||
defensiveAssignments,
|
||||
bench,
|
||||
notes
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { PlayerAvailabilitySummary } from "@/lib/types";
|
||||
import { daysBetween, toDateOnly } from "@/lib/utils";
|
||||
|
||||
interface PlayerLike {
|
||||
_id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
primaryPosition: string;
|
||||
secondaryPosition?: string | null;
|
||||
tertiaryPosition?: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface AppearanceLike {
|
||||
playerId: string;
|
||||
gameDate: Date | string;
|
||||
pitchCount: number;
|
||||
}
|
||||
|
||||
interface RuleLike {
|
||||
minPitches: number;
|
||||
maxPitches: number | null;
|
||||
requiredRestDays: number;
|
||||
}
|
||||
|
||||
export function resolveRestDays(pitchCount: number, rules: RuleLike[]) {
|
||||
const match = rules.find(
|
||||
(rule) => pitchCount >= rule.minPitches && (rule.maxPitches === null || pitchCount <= rule.maxPitches)
|
||||
);
|
||||
|
||||
return match?.requiredRestDays ?? 0;
|
||||
}
|
||||
|
||||
export function summarizePitcherAvailability(
|
||||
playerId: string,
|
||||
appearances: AppearanceLike[],
|
||||
rules: RuleLike[],
|
||||
gameDate: string
|
||||
): PlayerAvailabilitySummary {
|
||||
const latest = appearances
|
||||
.filter((appearance) => appearance.playerId === playerId)
|
||||
.sort((a, b) => new Date(b.gameDate).getTime() - new Date(a.gameDate).getTime())[0];
|
||||
|
||||
if (!latest) {
|
||||
return {
|
||||
status: "unknown",
|
||||
daysSinceLastAppearance: null,
|
||||
requiredRestDays: null,
|
||||
lastPitchedDate: null,
|
||||
lastPitchCount: null
|
||||
};
|
||||
}
|
||||
|
||||
const daysSinceLastAppearance = daysBetween(latest.gameDate, gameDate);
|
||||
const requiredRestDays = resolveRestDays(latest.pitchCount, rules);
|
||||
const status = daysSinceLastAppearance >= requiredRestDays ? "available" : "needs-rest";
|
||||
|
||||
return {
|
||||
status,
|
||||
daysSinceLastAppearance,
|
||||
requiredRestDays,
|
||||
lastPitchedDate: toDateOnly(latest.gameDate),
|
||||
lastPitchCount: latest.pitchCount
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPitcherAvailabilityMap(players: PlayerLike[], appearances: AppearanceLike[], rules: RuleLike[], gameDate: string) {
|
||||
return Object.fromEntries(
|
||||
players.map((player) => [player._id, summarizePitcherAvailability(player._id, appearances, rules, gameDate)])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function serialize<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
export type Handedness = "R/R" | "R/L" | "L/L" | "L/R" | "Switch";
|
||||
|
||||
export type Position =
|
||||
| "P"
|
||||
| "C"
|
||||
| "1B"
|
||||
| "2B"
|
||||
| "3B"
|
||||
| "SS"
|
||||
| "LF"
|
||||
| "CF"
|
||||
| "RF"
|
||||
| "UTIL";
|
||||
|
||||
export type PitcherAvailability = "available" | "needs-rest" | "unknown";
|
||||
|
||||
export interface PlayerStatsInput {
|
||||
gamesPlayed?: number | null;
|
||||
atBats?: number | null;
|
||||
runs?: number | null;
|
||||
hits?: number | null;
|
||||
doubles?: number | null;
|
||||
triples?: number | null;
|
||||
homeRuns?: number | null;
|
||||
runsBattedIn?: number | null;
|
||||
walks?: number | null;
|
||||
strikeouts?: number | null;
|
||||
stolenBases?: number | null;
|
||||
inningsPitchedOuts?: number | null;
|
||||
earnedRunsAllowed?: number | null;
|
||||
hitsAllowed?: number | null;
|
||||
walksAllowed?: number | null;
|
||||
strikeoutsPitched?: number | null;
|
||||
}
|
||||
|
||||
export interface PlayerPitchingInput {
|
||||
appearanceId?: string | null;
|
||||
gameDate?: string | null;
|
||||
pitchCount?: number | null;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface GamePlayerStatsInput {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
pitchCount?: number | null;
|
||||
atBats?: number | null;
|
||||
runs?: number | null;
|
||||
hits?: number | null;
|
||||
doubles?: number | null;
|
||||
triples?: number | null;
|
||||
homeRuns?: number | null;
|
||||
runsBattedIn?: number | null;
|
||||
walks?: number | null;
|
||||
strikeouts?: number | null;
|
||||
stolenBases?: number | null;
|
||||
inningsPitchedOuts?: number | null;
|
||||
earnedRunsAllowed?: number | null;
|
||||
hitsAllowed?: number | null;
|
||||
walksAllowed?: number | null;
|
||||
strikeoutsPitched?: number | null;
|
||||
}
|
||||
|
||||
export interface PitchingRuleInput {
|
||||
minPitches: number;
|
||||
maxPitches: number | null;
|
||||
requiredRestDays: number;
|
||||
}
|
||||
|
||||
export interface PlayerInput {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jerseyNumber: number;
|
||||
batsThrows: Handedness;
|
||||
primaryPosition: Position;
|
||||
secondaryPosition?: Position | null;
|
||||
tertiaryPosition?: Position | null;
|
||||
isActive: boolean;
|
||||
notes?: string;
|
||||
stats?: PlayerStatsInput;
|
||||
}
|
||||
|
||||
export interface PlayerRecord extends PlayerInput {
|
||||
_id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
pitcherAvailability: PlayerAvailabilitySummary;
|
||||
latestPitching?: PlayerPitchingInput | null;
|
||||
}
|
||||
|
||||
export interface PitchingAppearanceInput {
|
||||
playerId: string;
|
||||
gameDate: string;
|
||||
pitchCount: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface DefensiveAssignment {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
position: Position;
|
||||
}
|
||||
|
||||
export interface BattingSlot {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
slot: number;
|
||||
}
|
||||
|
||||
export interface GeneratedLineup {
|
||||
gameDate: string;
|
||||
startingPitcherId?: string | null;
|
||||
battingOrder: BattingSlot[];
|
||||
defensiveAssignments: DefensiveAssignment[];
|
||||
bench: string[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface PitchingRuleRecord extends PitchingRuleInput {
|
||||
_id?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface PitchingAppearanceRecord {
|
||||
_id: string;
|
||||
playerId: string;
|
||||
gameDate: string;
|
||||
pitchCount: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface LineupRecord extends GeneratedLineup {
|
||||
_id?: string;
|
||||
lineupNumber: number;
|
||||
playerIds: string[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PlayerAvailabilitySummary {
|
||||
status: PitcherAvailability;
|
||||
daysSinceLastAppearance: number | null;
|
||||
requiredRestDays: number | null;
|
||||
lastPitchedDate: string | null;
|
||||
lastPitchCount: number | null;
|
||||
}
|
||||
|
||||
export interface GameRecord {
|
||||
_id?: string;
|
||||
gameDate: string;
|
||||
gameNumber: number;
|
||||
lineupId?: string | null;
|
||||
lineupNumber?: number | null;
|
||||
opponent?: string;
|
||||
teamScore?: number | null;
|
||||
opponentScore?: number | null;
|
||||
notes?: string;
|
||||
playerStats: GamePlayerStatsInput[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
rosterCount: number;
|
||||
activePlayers: number;
|
||||
availablePitchers: number;
|
||||
needsRest: number;
|
||||
unknownPitchers: number;
|
||||
recentPitching: Array<{
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
status: PitcherAvailability;
|
||||
daysSinceLastAppearance: number | null;
|
||||
requiredRestDays: number | null;
|
||||
lastPitchedDate: string | null;
|
||||
pitchCount: number | null;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { PitcherAvailability } from "@/lib/types";
|
||||
|
||||
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return twMerge(clsx(parts));
|
||||
}
|
||||
|
||||
export function formatPlayerName(firstName: string, lastName: string) {
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
|
||||
export function toDateOnly(date: Date | string) {
|
||||
const target = typeof date === "string" ? new Date(date) : date;
|
||||
return target.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function startOfDay(date: Date | string) {
|
||||
const target = typeof date === "string" ? new Date(date) : new Date(date);
|
||||
target.setUTCHours(0, 0, 0, 0);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function daysBetween(from: Date | string, to: Date | string) {
|
||||
const left = startOfDay(from).getTime();
|
||||
const right = startOfDay(to).getTime();
|
||||
return Math.floor((right - left) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export function statusLabel(status: PitcherAvailability) {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return "Available";
|
||||
case "needs-rest":
|
||||
return "Needs Rest";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function sortBy<T>(items: T[], getter: (item: T) => string | number) {
|
||||
return [...items].sort((a, b) => {
|
||||
const left = getter(a);
|
||||
const right = getter(b);
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from "zod";
|
||||
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
|
||||
|
||||
const optionalStat = z.union([z.coerce.number().int().min(0), z.null()]).optional();
|
||||
|
||||
export const playerSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "First name is required."),
|
||||
lastName: z.string().trim().min(1, "Last name is required."),
|
||||
jerseyNumber: z.coerce.number().int().min(0).max(999),
|
||||
batsThrows: z.enum(HANDEDNESS_OPTIONS),
|
||||
primaryPosition: z.enum(POSITIONS),
|
||||
secondaryPosition: z.enum(POSITIONS).nullable().optional(),
|
||||
tertiaryPosition: z.enum(POSITIONS).nullable().optional(),
|
||||
isActive: z.boolean(),
|
||||
notes: z.string().max(500).optional().default(""),
|
||||
stats: z
|
||||
.object({
|
||||
gamesPlayed: optionalStat,
|
||||
atBats: optionalStat,
|
||||
runs: optionalStat,
|
||||
hits: optionalStat,
|
||||
doubles: optionalStat,
|
||||
triples: optionalStat,
|
||||
homeRuns: optionalStat,
|
||||
runsBattedIn: optionalStat,
|
||||
walks: optionalStat,
|
||||
strikeouts: optionalStat,
|
||||
stolenBases: optionalStat,
|
||||
inningsPitchedOuts: optionalStat,
|
||||
earnedRunsAllowed: optionalStat,
|
||||
hitsAllowed: optionalStat,
|
||||
walksAllowed: optionalStat,
|
||||
strikeoutsPitched: optionalStat
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const pitchingAppearanceSchema = z.object({
|
||||
playerId: z.string().min(1),
|
||||
gameDate: z.string().min(1),
|
||||
pitchCount: z.coerce.number().int().min(1).max(200),
|
||||
notes: z.string().max(300).optional().default("")
|
||||
});
|
||||
|
||||
export const pitchingRulesSchema = z.array(
|
||||
z.object({
|
||||
minPitches: z.coerce.number().int().min(0),
|
||||
maxPitches: z.coerce.number().int().positive().nullable(),
|
||||
requiredRestDays: z.coerce.number().int().min(0).max(10)
|
||||
})
|
||||
).min(1);
|
||||
|
||||
export const generateLineupSchema = z.object({
|
||||
gameDate: z.string().min(1),
|
||||
playerIds: z.array(z.string()).min(1, "Select at least one player."),
|
||||
startingPitcherId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const saveLineupSchema = generateLineupSchema.extend({
|
||||
battingOrder: z.array(
|
||||
z.object({
|
||||
playerId: z.string(),
|
||||
playerName: z.string(),
|
||||
slot: z.coerce.number().int().positive()
|
||||
})
|
||||
).min(1),
|
||||
defensiveAssignments: z.array(
|
||||
z.object({
|
||||
playerId: z.string(),
|
||||
playerName: z.string(),
|
||||
position: z.enum(POSITIONS)
|
||||
})
|
||||
),
|
||||
bench: z.array(z.string()),
|
||||
notes: z.array(z.string())
|
||||
});
|
||||
|
||||
export const saveGameSchema = z.object({
|
||||
gameDate: z.string().min(1),
|
||||
lineupId: z.string().min(1),
|
||||
opponent: z.string().max(120).optional().default(""),
|
||||
teamScore: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
|
||||
opponentScore: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
|
||||
notes: z.string().max(1000).optional().default(""),
|
||||
playerStats: z.array(
|
||||
z.object({
|
||||
playerId: z.string().min(1),
|
||||
playerName: z.string().min(1),
|
||||
atBats: optionalStat,
|
||||
runs: optionalStat,
|
||||
hits: optionalStat,
|
||||
doubles: optionalStat,
|
||||
triples: optionalStat,
|
||||
homeRuns: optionalStat,
|
||||
runsBattedIn: optionalStat,
|
||||
walks: optionalStat,
|
||||
strikeouts: optionalStat,
|
||||
stolenBases: optionalStat,
|
||||
pitchCount: optionalStat,
|
||||
inningsPitchedOuts: optionalStat,
|
||||
earnedRunsAllowed: optionalStat,
|
||||
hitsAllowed: optionalStat,
|
||||
walksAllowed: optionalStat,
|
||||
strikeoutsPitched: optionalStat
|
||||
})
|
||||
)
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Model, Schema, Types, model, models } from "mongoose";
|
||||
|
||||
export interface GameDocument {
|
||||
gameDate: Date;
|
||||
gameNumber: number;
|
||||
activePlayerIds: Types.ObjectId[];
|
||||
startingPitcherId?: Types.ObjectId | null;
|
||||
lineupId?: Types.ObjectId | null;
|
||||
lineupNumber?: number | null;
|
||||
opponent?: string;
|
||||
teamScore?: number | null;
|
||||
opponentScore?: number | null;
|
||||
notes?: string;
|
||||
playerStats: Array<{
|
||||
playerId: Types.ObjectId;
|
||||
playerName: string;
|
||||
pitchCount?: number | null;
|
||||
atBats?: number | null;
|
||||
runs?: number | null;
|
||||
hits?: number | null;
|
||||
doubles?: number | null;
|
||||
triples?: number | null;
|
||||
homeRuns?: number | null;
|
||||
runsBattedIn?: number | null;
|
||||
walks?: number | null;
|
||||
strikeouts?: number | null;
|
||||
stolenBases?: number | null;
|
||||
inningsPitchedOuts?: number | null;
|
||||
earnedRunsAllowed?: number | null;
|
||||
hitsAllowed?: number | null;
|
||||
walksAllowed?: number | null;
|
||||
strikeoutsPitched?: number | null;
|
||||
}>;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const gameSchema = new Schema<GameDocument>(
|
||||
{
|
||||
gameDate: { type: Date, required: true, index: true },
|
||||
gameNumber: { type: Number, required: true, min: 1 },
|
||||
activePlayerIds: [{ type: Schema.Types.ObjectId, ref: "Player" }],
|
||||
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
|
||||
lineupId: { type: Schema.Types.ObjectId, ref: "Lineup", default: null },
|
||||
lineupNumber: { type: Number, default: null },
|
||||
opponent: { type: String, default: "" },
|
||||
teamScore: { type: Number, default: null },
|
||||
opponentScore: { type: Number, default: null },
|
||||
notes: { type: String, default: "" },
|
||||
playerStats: [
|
||||
{
|
||||
playerId: { type: Schema.Types.ObjectId, ref: "Player", required: true },
|
||||
playerName: { type: String, required: true },
|
||||
atBats: { type: Number, default: null },
|
||||
runs: { type: Number, default: null },
|
||||
hits: { type: Number, default: null },
|
||||
doubles: { type: Number, default: null },
|
||||
triples: { type: Number, default: null },
|
||||
homeRuns: { type: Number, default: null },
|
||||
runsBattedIn: { type: Number, default: null },
|
||||
walks: { type: Number, default: null },
|
||||
strikeouts: { type: Number, default: null },
|
||||
stolenBases: { type: Number, default: null },
|
||||
pitchCount: { type: Number, default: null },
|
||||
inningsPitchedOuts: { type: Number, default: null },
|
||||
earnedRunsAllowed: { type: Number, default: null },
|
||||
hitsAllowed: { type: Number, default: null },
|
||||
walksAllowed: { type: Number, default: null },
|
||||
strikeoutsPitched: { type: Number, default: null }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
gameSchema.index({ gameDate: 1, gameNumber: 1 }, { unique: true });
|
||||
|
||||
export const GameModel = (models.Game as Model<GameDocument>) || model<GameDocument>("Game", gameSchema);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Model, Schema, Types, model, models } from "mongoose";
|
||||
|
||||
export interface LineupDocument {
|
||||
gameDate: Date;
|
||||
lineupNumber: number;
|
||||
playerIds: Types.ObjectId[];
|
||||
startingPitcherId?: Types.ObjectId | null;
|
||||
battingOrder: Array<{
|
||||
playerId: Types.ObjectId;
|
||||
playerName: string;
|
||||
slot: number;
|
||||
}>;
|
||||
defensiveAssignments: Array<{
|
||||
playerId: Types.ObjectId;
|
||||
playerName: string;
|
||||
position: string;
|
||||
}>;
|
||||
bench: Types.ObjectId[];
|
||||
notes: string[];
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const lineupSchema = new Schema<LineupDocument>(
|
||||
{
|
||||
gameDate: { type: Date, required: true, index: true },
|
||||
lineupNumber: { type: Number, required: true, min: 1 },
|
||||
playerIds: [{ type: Schema.Types.ObjectId, ref: "Player", required: true }],
|
||||
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
|
||||
battingOrder: [
|
||||
{
|
||||
playerId: { type: Schema.Types.ObjectId, ref: "Player", required: true },
|
||||
playerName: { type: String, required: true },
|
||||
slot: { type: Number, required: true }
|
||||
}
|
||||
],
|
||||
defensiveAssignments: [
|
||||
{
|
||||
playerId: { type: Schema.Types.ObjectId, ref: "Player", required: true },
|
||||
playerName: { type: String, required: true },
|
||||
position: { type: String, required: true }
|
||||
}
|
||||
],
|
||||
bench: [{ type: Schema.Types.ObjectId, ref: "Player" }],
|
||||
notes: [{ type: String }]
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
lineupSchema.index({ gameDate: 1, lineupNumber: 1 }, { unique: true });
|
||||
|
||||
export const LineupModel = (models.Lineup as Model<LineupDocument>) || model<LineupDocument>("Lineup", lineupSchema);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user