2-2-create-election-cycle-job içindeki 1 işleme main ile birleştirdi
| @@ -24,3 +24,5 @@ docker-compose.yml | |||
| Campaign_Tracker.Server/audit-logs/ | |||
| Campaign_Tracker.Server/legacy-schema-history.jsonl | |||
| Campaign_Tracker.Server/seed-data.json | |||
| graphify-out/ | |||
| @@ -0,0 +1,192 @@ | |||
| using System.Net; | |||
| using System.Net.Http.Headers; | |||
| using System.Net.Http.Json; | |||
| using Campaign_Tracker.Server.Audit; | |||
| using Campaign_Tracker.Server.ElectionCycles; | |||
| using Microsoft.Extensions.DependencyInjection; | |||
| namespace Campaign_Tracker.Server.Tests; | |||
| public sealed partial class ElectionCycleJobControllerTests | |||
| { | |||
| [Fact] | |||
| public async Task CreateJob_ValidRequestWithExistingCycle_Returns201AndInSetupStatus_AC1_AC3() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var client = CreateClient(factory); | |||
| var response = await client.PostAsJsonAsync( | |||
| "/api/election-cycles/jobs", | |||
| new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); | |||
| Assert.Equal(HttpStatusCode.Created, response.StatusCode); | |||
| var body = await response.Content.ReadFromJsonAsync<ElectionCycleJobDto>(); | |||
| Assert.NotNull(body); | |||
| Assert.Equal("FAIR01", body.JCode); | |||
| Assert.Equal("2026-primary", body.CycleId); | |||
| Assert.Equal("2026 Primary", body.CycleName); | |||
| Assert.Equal("In Setup", body.Status); | |||
| Assert.Equal("cs@example.test", body.CreatedBy); | |||
| } | |||
| [Fact] | |||
| public async Task CreateJob_NewCycleName_GeneratesCycleIdAndCreatesJob_AC1() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var client = CreateClient(factory); | |||
| var response = await client.PostAsJsonAsync( | |||
| "/api/election-cycles/jobs", | |||
| new { jCode = "PINE03", cycleName = "2027 General" }); | |||
| Assert.Equal(HttpStatusCode.Created, response.StatusCode); | |||
| var body = await response.Content.ReadFromJsonAsync<ElectionCycleJobDto>(); | |||
| Assert.NotNull(body); | |||
| Assert.Equal("PINE03", body.JCode); | |||
| Assert.Equal("2027-general", body.CycleId); | |||
| Assert.Equal("2027 General", body.CycleName); | |||
| Assert.Equal("In Setup", body.Status); | |||
| } | |||
| [Fact] | |||
| public async Task CreateJob_MissingCycleSelection_Returns422WithValidationMessage_AC4() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var client = CreateClient(factory); | |||
| var response = await client.PostAsJsonAsync( | |||
| "/api/election-cycles/jobs", | |||
| new { jCode = "FAIR01" }); | |||
| Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); | |||
| var problem = await response.Content.ReadFromJsonAsync<ElectionCycleJobProblemDto>(); | |||
| Assert.NotNull(problem); | |||
| Assert.Contains("required", problem.Error, StringComparison.OrdinalIgnoreCase); | |||
| } | |||
| [Fact] | |||
| public async Task CreateJob_MissingJCode_ReturnsClientError_AC4() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var client = CreateClient(factory); | |||
| var response = await client.PostAsJsonAsync( | |||
| "/api/election-cycles/jobs", | |||
| new { cycleId = "2026-primary", cycleName = "2026 Primary" }); | |||
| // Either 400 (model binding) or 422 (our validation) — both reject the request. | |||
| Assert.True(response.StatusCode == HttpStatusCode.BadRequest || | |||
| response.StatusCode == HttpStatusCode.UnprocessableEntity); | |||
| } | |||
| [Fact] | |||
| public async Task CreateJob_RejectsUnauthenticatedUser_AC5() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var noToken = factory.CreateClient(); | |||
| var response = await noToken.PostAsJsonAsync( | |||
| "/api/election-cycles/jobs", | |||
| new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); | |||
| Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); | |||
| } | |||
| [Fact] | |||
| public async Task CreateJob_RejectsNonClientServicesRole_AC5() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var client = factory.CreateClient(); | |||
| client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( | |||
| "Bearer", AuthIntegrationTestFactory.CreateToken("prod@example.test", "production")); | |||
| var response = await client.PostAsJsonAsync( | |||
| "/api/election-cycles/jobs", | |||
| new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); | |||
| Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); | |||
| } | |||
| [Fact] | |||
| public async Task CreateJob_EmitsAuditEvent_AC5() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var client = CreateClient(factory); | |||
| var response = await client.PostAsJsonAsync( | |||
| "/api/election-cycles/jobs", | |||
| new { jCode = "LAKE02", cycleId = "2026-primary", cycleName = "2026 Primary" }); | |||
| Assert.Equal(HttpStatusCode.Created, response.StatusCode); | |||
| var auditService = factory.Services.GetRequiredService<IAuditService>(); | |||
| var events = auditService.GetRecent(); | |||
| Assert.Contains(events, e => | |||
| e.EventType == "ELECTION_CYCLE_JOB_CREATED" && | |||
| e.ActorIdentity == "cs@example.test" && | |||
| e.Outcome.Contains("LAKE02")); | |||
| } | |||
| [Fact] | |||
| public async Task CreateJob_DoesNotWriteToLegacyTables_AC5() | |||
| { | |||
| // The repository is IElectionCycleJobRepository — an extension-layer write path. | |||
| // ILegacyDataAccess has only Get* methods (read-only). Verify the interface contract. | |||
| var legacyMethods = typeof(Campaign_Tracker.Server.LegacyData.ILegacyDataAccess).GetMethods(); | |||
| Assert.True( | |||
| legacyMethods.All(m => m.Name.StartsWith("Get", StringComparison.Ordinal)), | |||
| "ILegacyDataAccess must only expose read methods — no writes to legacy tables."); | |||
| } | |||
| [Fact] | |||
| public async Task CreateJob_IdempotentForSameJCodeAndCycle() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var client = CreateClient(factory); | |||
| var response1 = await client.PostAsJsonAsync( | |||
| "/api/election-cycles/jobs", | |||
| new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); | |||
| var response2 = await client.PostAsJsonAsync( | |||
| "/api/election-cycles/jobs", | |||
| new { jCode = "FAIR01", cycleId = "2026-primary", cycleName = "2026 Primary" }); | |||
| Assert.Equal(HttpStatusCode.Created, response1.StatusCode); | |||
| Assert.Equal(HttpStatusCode.Created, response2.StatusCode); | |||
| var body1 = await response1.Content.ReadFromJsonAsync<ElectionCycleJobDto>(); | |||
| var body2 = await response2.Content.ReadFromJsonAsync<ElectionCycleJobDto>(); | |||
| Assert.NotNull(body1); | |||
| Assert.NotNull(body2); | |||
| Assert.Equal(body1.JobId, body2.JobId); | |||
| } | |||
| [Fact] | |||
| public async Task GetById_UnknownJob_Returns404() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var client = CreateClient(factory); | |||
| var response = await client.GetAsync("/api/election-cycles/jobs/does-not-exist"); | |||
| Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); | |||
| } | |||
| private static HttpClient CreateClient(AuthIntegrationTestFactory factory) | |||
| { | |||
| var client = factory.CreateClient(); | |||
| client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( | |||
| "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); | |||
| return client; | |||
| } | |||
| private sealed record ElectionCycleJobDto( | |||
| string JobId, | |||
| string JCode, | |||
| string CycleId, | |||
| string CycleName, | |||
| string Status, | |||
| string CreatedBy, | |||
| string CreatedAt); | |||
| private sealed record ElectionCycleJobProblemDto(string Error); | |||
| } | |||
| @@ -0,0 +1,185 @@ | |||
| using System.Net; | |||
| using System.Net.Http.Headers; | |||
| using System.Net.Http.Json; | |||
| using Campaign_Tracker.Server.ElectionCycles; | |||
| using Campaign_Tracker.Server.ExtensionData; | |||
| using Campaign_Tracker.Server.LegacyData; | |||
| using Campaign_Tracker.Server.LegacyData.Models; | |||
| using Campaign_Tracker.Server.Municipalities; | |||
| using Microsoft.Extensions.DependencyInjection; | |||
| namespace Campaign_Tracker.Server.Tests; | |||
| public sealed class ElectionCycleKanbanReadModelTests | |||
| { | |||
| [Fact] | |||
| public async Task GetKanban_GroupsActiveAssignmentsAndUnassignedMunicipalities_AC1_AC3() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var client = CreateClient(factory, "client-services"); | |||
| await CreateProfile(client, "FAIR01", "Fairview Display"); | |||
| await CreateProfile(client, "LAKE02", null); | |||
| await CreateProfile(client, "PINE03", null); | |||
| var response = await client.GetAsync("/api/election-cycles/kanban"); | |||
| Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |||
| var board = await response.Content.ReadFromJsonAsync<ElectionCycleKanbanDto>(); | |||
| Assert.NotNull(board); | |||
| var primary = Assert.Single(board.Lanes, lane => lane.CycleId == "2026-primary"); | |||
| Assert.Equal("2026 Primary", primary.CycleName); | |||
| var fairview = Assert.Single(primary.Cards, card => card.JCode == "FAIR01"); | |||
| Assert.Equal("Fairview Display", fairview.MunicipalityName); | |||
| Assert.Equal("Ready", fairview.CycleJobStatus); | |||
| Assert.Equal("FAIR01", fairview.LegacyJoinKey); | |||
| Assert.Equal("/election-cycles/jobs/job-fair01-primary", fairview.QuickOpenHref); | |||
| var unassigned = Assert.Single(board.Lanes, lane => lane.CycleId == ElectionCycleKanbanReadModel.UnassignedCycleId); | |||
| Assert.Contains(unassigned.Cards, card => | |||
| card.JCode == "PINE03" && | |||
| card.CycleName == "Unassigned" && | |||
| card.CycleJobStatus == "Unassigned"); | |||
| } | |||
| [Fact] | |||
| public async Task ReadModel_ReturnsOneCardPerMunicipalityCyclePair_AC2() | |||
| { | |||
| var legacy = new InMemoryLegacyDataAccess(); | |||
| var profiles = BuildProfileRepository(legacy); | |||
| await profiles.CreateAsync("LAKE02", null, "test@example.test"); | |||
| var jobs = new InMemoryElectionCycleJobRepository( | |||
| [ | |||
| new ElectionCycleJobAssignment( | |||
| "job-lake-primary", | |||
| "LAKE02", | |||
| "2026-primary", | |||
| "2026 Primary", | |||
| "In progress", | |||
| IsActive: true), | |||
| new ElectionCycleJobAssignment( | |||
| "job-lake-special", | |||
| "LAKE02", | |||
| "2026-special", | |||
| "2026 Special", | |||
| "Blocked", | |||
| IsActive: true), | |||
| ], TimeProvider.System); | |||
| var sut = new ElectionCycleKanbanReadModel(profiles, jobs); | |||
| var board = await sut.GetAsync(); | |||
| var lakeCards = board.Lanes.SelectMany(lane => lane.Cards) | |||
| .Where(card => card.JCode == "LAKE02") | |||
| .ToArray(); | |||
| Assert.Equal(2, lakeCards.Length); | |||
| Assert.Contains(lakeCards, card => card.CycleId == "2026-primary"); | |||
| Assert.Contains(lakeCards, card => card.CycleId == "2026-special"); | |||
| } | |||
| [Fact] | |||
| public async Task ReadModel_ExcludesInactiveAssignmentsFromCycleLanes_AC1() | |||
| { | |||
| var legacy = new InMemoryLegacyDataAccess(); | |||
| var profiles = BuildProfileRepository(legacy); | |||
| await profiles.CreateAsync("FAIR01", null, "test@example.test"); | |||
| var jobs = new InMemoryElectionCycleJobRepository( | |||
| [ | |||
| new ElectionCycleJobAssignment( | |||
| "job-fair-old", | |||
| "FAIR01", | |||
| "2024-general", | |||
| "2024 General", | |||
| "Complete", | |||
| IsActive: false), | |||
| ], TimeProvider.System); | |||
| var sut = new ElectionCycleKanbanReadModel(profiles, jobs); | |||
| var board = await sut.GetAsync(); | |||
| Assert.DoesNotContain(board.Lanes, lane => lane.CycleId == "2024-general"); | |||
| var unassigned = Assert.Single(board.Lanes, lane => lane.CycleId == ElectionCycleKanbanReadModel.UnassignedCycleId); | |||
| Assert.Contains(unassigned.Cards, card => card.JCode == "FAIR01"); | |||
| } | |||
| [Fact] | |||
| public async Task GetKanban_RequiresClientServicesRole_AC1() | |||
| { | |||
| await using var factory = new AuthIntegrationTestFactory(); | |||
| using var noToken = factory.CreateClient(); | |||
| using var production = CreateClient(factory, "production"); | |||
| var missingToken = await noToken.GetAsync("/api/election-cycles/kanban"); | |||
| var wrongRole = await production.GetAsync("/api/election-cycles/kanban"); | |||
| Assert.Equal(HttpStatusCode.Unauthorized, missingToken.StatusCode); | |||
| Assert.Equal(HttpStatusCode.Forbidden, wrongRole.StatusCode); | |||
| } | |||
| [Fact] | |||
| public async Task ReadModel_UsesReadOnlyLegacyAndExtensionRepositories_AC1() | |||
| { | |||
| ILegacyDataAccess legacy = new InMemoryLegacyDataAccess( | |||
| jurisdictions: | |||
| [ | |||
| new("FAIR01", "Fairview Borough", "100 Main St", "Fairview, PA 16415", null, null), | |||
| ]); | |||
| var profiles = BuildProfileRepository(legacy); | |||
| await profiles.CreateAsync("FAIR01", null, "test@example.test"); | |||
| var jobs = new InMemoryElectionCycleJobRepository([], TimeProvider.System); | |||
| var sut = new ElectionCycleKanbanReadModel(profiles, jobs); | |||
| var board = await sut.GetAsync(); | |||
| Assert.Contains(board.Lanes.Single(lane => lane.CycleId == ElectionCycleKanbanReadModel.UnassignedCycleId).Cards, | |||
| card => card.JCode == "FAIR01"); | |||
| Assert.True(typeof(ILegacyDataAccess).GetMethods().All(method => | |||
| method.Name.StartsWith("Get", StringComparison.Ordinal))); | |||
| } | |||
| private static InMemoryMunicipalityProfileRepository BuildProfileRepository( | |||
| ILegacyDataAccess legacy) | |||
| => new( | |||
| new LegacyLinkValidator(legacy), | |||
| legacy, | |||
| TimeProvider.System); | |||
| private static HttpClient CreateClient(AuthIntegrationTestFactory factory, string role) | |||
| { | |||
| var client = factory.CreateClient(); | |||
| client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( | |||
| "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", role)); | |||
| return client; | |||
| } | |||
| private static async Task CreateProfile( | |||
| HttpClient client, | |||
| string jCode, | |||
| string? displayName) | |||
| { | |||
| var response = await client.PostAsJsonAsync("/api/municipalities/profiles", new | |||
| { | |||
| jCode, | |||
| displayName, | |||
| }); | |||
| Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |||
| } | |||
| private sealed record ElectionCycleKanbanDto(ElectionCycleKanbanLaneDto[] Lanes); | |||
| private sealed record ElectionCycleKanbanLaneDto( | |||
| string CycleId, | |||
| string CycleName, | |||
| ElectionCycleKanbanCardDto[] Cards); | |||
| private sealed record ElectionCycleKanbanCardDto( | |||
| string CardId, | |||
| string MunicipalityName, | |||
| string JCode, | |||
| string CycleId, | |||
| string CycleName, | |||
| string CycleJobStatus, | |||
| string LegacyJoinKey, | |||
| string QuickOpenHref); | |||
| } | |||
| @@ -0,0 +1,134 @@ | |||
| using System.Security.Claims; | |||
| using Campaign_Tracker.Server.Audit; | |||
| using Campaign_Tracker.Server.Authorization; | |||
| using Campaign_Tracker.Server.ElectionCycles; | |||
| using Microsoft.AspNetCore.Authorization; | |||
| using Microsoft.AspNetCore.Mvc; | |||
| namespace Campaign_Tracker.Server.Controllers; | |||
| [ApiController] | |||
| [Authorize(Policy = ApplicationPolicy.ClientServicesAccess)] | |||
| [Route("api/election-cycles/jobs")] | |||
| public sealed class ElectionCycleJobsController : ControllerBase | |||
| { | |||
| private readonly IElectionCycleJobRepository _jobs; | |||
| private readonly IAuditService _audit; | |||
| private readonly TimeProvider _timeProvider; | |||
| public ElectionCycleJobsController( | |||
| IElectionCycleJobRepository jobs, | |||
| IAuditService audit, | |||
| TimeProvider timeProvider) | |||
| { | |||
| _jobs = jobs; | |||
| _audit = audit; | |||
| _timeProvider = timeProvider; | |||
| } | |||
| [HttpPost] | |||
| public async Task<ActionResult<ElectionCycleJobResponse>> Create( | |||
| [FromBody] CreateElectionCycleJobRequest request, | |||
| CancellationToken cancellationToken) | |||
| { | |||
| if (string.IsNullOrWhiteSpace(request.JCode)) | |||
| return UnprocessableEntity(new ElectionCycleJobProblem("Municipality identifier (JCode) is required.")); | |||
| if (string.IsNullOrWhiteSpace(request.CycleId) && string.IsNullOrWhiteSpace(request.CycleName)) | |||
| return UnprocessableEntity(new ElectionCycleJobProblem("Cycle selection is required — provide an existing cycle or a new cycle name.")); | |||
| var cycleId = string.IsNullOrWhiteSpace(request.CycleId) | |||
| ? NormalizeNewCycleId(request.CycleName!) | |||
| : request.CycleId.Trim(); | |||
| var cycleName = string.IsNullOrWhiteSpace(request.CycleName) | |||
| ? cycleId // fallback — shouldn't happen given validation above | |||
| : request.CycleName.Trim(); | |||
| var actor = GetActor(); | |||
| var result = await _jobs.CreateAsync( | |||
| request.JCode, | |||
| cycleId, | |||
| cycleName, | |||
| actor, | |||
| cancellationToken); | |||
| if (!result.Saved || result.Job is null) | |||
| return UnprocessableEntity(new ElectionCycleJobProblem(result.Error ?? "Job creation failed.")); | |||
| _audit.Record(new AuditEvent( | |||
| EventType: "ELECTION_CYCLE_JOB_CREATED", | |||
| ActorIdentity: actor, | |||
| Resource: $"election-cycles/jobs/{result.Job.JobId}", | |||
| Outcome: $"created job for {request.JCode} in cycle {cycleName}", | |||
| TraceIdentifier: HttpContext.TraceIdentifier, | |||
| RecordedAt: _timeProvider.GetUtcNow())); | |||
| return CreatedAtAction( | |||
| nameof(GetById), | |||
| new { jobId = result.Job.JobId }, | |||
| ElectionCycleJobResponse.From(result.Job)); | |||
| } | |||
| [HttpGet("{jobId}")] | |||
| public async Task<ActionResult<ElectionCycleJobResponse>> GetById( | |||
| string jobId, | |||
| CancellationToken cancellationToken) | |||
| { | |||
| var allAssignments = await _jobs.GetAllAsync(cancellationToken); | |||
| var match = allAssignments.FirstOrDefault(a => | |||
| string.Equals(a.JobId, jobId, StringComparison.OrdinalIgnoreCase)); | |||
| if (match is null) | |||
| return NotFound(); | |||
| // For dynamically created jobs we don't have full entity here from assignments; | |||
| // the kanban read model serves as the source of truth for listing. | |||
| // This endpoint returns the assignment data mapped to a response shape. | |||
| return Ok(new ElectionCycleJobResponse( | |||
| JobId: match.JobId, | |||
| JCode: match.JCode, | |||
| CycleId: match.CycleId, | |||
| CycleName: match.CycleName, | |||
| Status: match.Status, | |||
| CreatedBy: "system", | |||
| CreatedAt: DateTimeOffset.UtcNow.ToString("O"))); | |||
| } | |||
| private static string NormalizeNewCycleId(string cycleName) => | |||
| cycleName.ToLowerInvariant() | |||
| .Replace(" ", "-") | |||
| .Replace("'", "") | |||
| .Replace(",", ""); | |||
| private string GetActor() => | |||
| User.Identity?.Name | |||
| ?? User.FindFirstValue(ClaimTypes.NameIdentifier) | |||
| ?? "unknown"; | |||
| } | |||
| public sealed record CreateElectionCycleJobRequest( | |||
| string JCode, | |||
| string? CycleId, | |||
| string? CycleName); | |||
| public sealed record ElectionCycleJobResponse( | |||
| string JobId, | |||
| string JCode, | |||
| string CycleId, | |||
| string CycleName, | |||
| string Status, | |||
| string CreatedBy, | |||
| string CreatedAt) | |||
| { | |||
| public static ElectionCycleJobResponse From(ElectionCycleJob job) => | |||
| new( | |||
| JobId: job.JobId, | |||
| JCode: job.JCode, | |||
| CycleId: job.CycleId, | |||
| CycleName: job.CycleName, | |||
| Status: job.Status, | |||
| CreatedBy: job.CreatedBy, | |||
| CreatedAt: job.CreatedAt.ToString("O")); | |||
| } | |||
| public sealed record ElectionCycleJobProblem(string Error); | |||
| @@ -0,0 +1,24 @@ | |||
| using Campaign_Tracker.Server.Authorization; | |||
| using Campaign_Tracker.Server.ElectionCycles; | |||
| using Microsoft.AspNetCore.Authorization; | |||
| using Microsoft.AspNetCore.Mvc; | |||
| namespace Campaign_Tracker.Server.Controllers; | |||
| [ApiController] | |||
| [Authorize(Policy = ApplicationPolicy.ClientServicesAccess)] | |||
| [Route("api/election-cycles/kanban")] | |||
| public sealed class ElectionCycleKanbanController : ControllerBase | |||
| { | |||
| private readonly IElectionCycleKanbanReadModel _kanban; | |||
| public ElectionCycleKanbanController(IElectionCycleKanbanReadModel kanban) | |||
| { | |||
| _kanban = kanban; | |||
| } | |||
| [HttpGet] | |||
| public async Task<ActionResult<ElectionCycleKanbanBoard>> Get( | |||
| CancellationToken cancellationToken) | |||
| => Ok(await _kanban.GetAsync(cancellationToken)); | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| namespace Campaign_Tracker.Server.ElectionCycles; | |||
| public sealed record ElectionCycleJob( | |||
| string JobId, | |||
| string JCode, | |||
| string CycleId, | |||
| string CycleName, | |||
| string Status, | |||
| string CreatedBy, | |||
| DateTimeOffset CreatedAt); | |||
| @@ -0,0 +1,9 @@ | |||
| namespace Campaign_Tracker.Server.ElectionCycles; | |||
| public sealed record ElectionCycleJobAssignment( | |||
| string JobId, | |||
| string JCode, | |||
| string CycleId, | |||
| string CycleName, | |||
| string Status, | |||
| bool IsActive); | |||
| @@ -0,0 +1,14 @@ | |||
| namespace Campaign_Tracker.Server.ElectionCycles; | |||
| public sealed record ElectionCycleJobSaveResult | |||
| { | |||
| public bool Saved { get; init; } | |||
| public string? Error { get; init; } | |||
| public ElectionCycleJob? Job { get; init; } | |||
| public static ElectionCycleJobSaveResult Success(ElectionCycleJob job) => | |||
| new() { Saved = true, Job = job }; | |||
| public static ElectionCycleJobSaveResult Failure(string error) => | |||
| new() { Saved = false, Error = error }; | |||
| } | |||
| @@ -0,0 +1,168 @@ | |||
| using Campaign_Tracker.Server.Municipalities; | |||
| namespace Campaign_Tracker.Server.ElectionCycles; | |||
| public interface IElectionCycleKanbanReadModel | |||
| { | |||
| Task<ElectionCycleKanbanBoard> GetAsync( | |||
| CancellationToken cancellationToken = default); | |||
| } | |||
| public sealed class ElectionCycleKanbanReadModel : IElectionCycleKanbanReadModel | |||
| { | |||
| public const string UnassignedCycleId = "__unassigned__"; | |||
| public const string UnassignedCycleName = "Unassigned"; | |||
| private readonly IMunicipalityProfileRepository _profiles; | |||
| private readonly IElectionCycleJobRepository _jobs; | |||
| public ElectionCycleKanbanReadModel( | |||
| IMunicipalityProfileRepository profiles, | |||
| IElectionCycleJobRepository jobs) | |||
| { | |||
| _profiles = profiles; | |||
| _jobs = jobs; | |||
| } | |||
| public async Task<ElectionCycleKanbanBoard> GetAsync( | |||
| CancellationToken cancellationToken = default) | |||
| { | |||
| var profiles = await _profiles.GetAllAsync(cancellationToken); | |||
| var activeJobs = (await _jobs.GetAllAsync(cancellationToken)) | |||
| .Where(job => job.IsActive) | |||
| .ToArray(); | |||
| var jobsByJCode = activeJobs | |||
| .GroupBy(job => job.JCode, StringComparer.OrdinalIgnoreCase) | |||
| .ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase); | |||
| var cardsByCycle = new Dictionary<string, List<ElectionCycleKanbanCard>>( | |||
| StringComparer.OrdinalIgnoreCase); | |||
| var lanesByCycle = activeJobs | |||
| .GroupBy(job => job.CycleId, StringComparer.OrdinalIgnoreCase) | |||
| .ToDictionary( | |||
| group => group.Key, | |||
| group => new ElectionCycleLaneHeader( | |||
| group.Key, | |||
| group.Select(job => job.CycleName) | |||
| .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) | |||
| .First()), | |||
| StringComparer.OrdinalIgnoreCase); | |||
| var unassignedCards = new List<ElectionCycleKanbanCard>(); | |||
| var matchedJobIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | |||
| foreach (var profile in profiles) | |||
| { | |||
| if (!jobsByJCode.TryGetValue(profile.Profile.JCode, out var profileJobs)) | |||
| { | |||
| unassignedCards.Add(BuildUnassignedCard(profile)); | |||
| continue; | |||
| } | |||
| foreach (var job in profileJobs.OrderBy(job => job.CycleName, StringComparer.OrdinalIgnoreCase)) | |||
| { | |||
| if (!cardsByCycle.TryGetValue(job.CycleId, out var cycleCards)) | |||
| { | |||
| cycleCards = []; | |||
| cardsByCycle[job.CycleId] = cycleCards; | |||
| } | |||
| cycleCards.Add(BuildAssignedCard(profile, job)); | |||
| matchedJobIds.Add(job.JobId); | |||
| } | |||
| } | |||
| foreach (var orphan in activeJobs.Where(job => !matchedJobIds.Contains(job.JobId))) | |||
| { | |||
| if (!cardsByCycle.TryGetValue(orphan.CycleId, out var cycleCards)) | |||
| { | |||
| cycleCards = []; | |||
| cardsByCycle[orphan.CycleId] = cycleCards; | |||
| } | |||
| cycleCards.Add(BuildOrphanedCard(orphan)); | |||
| } | |||
| var lanes = cardsByCycle | |||
| .OrderBy(pair => lanesByCycle[pair.Key].CycleName, StringComparer.OrdinalIgnoreCase) | |||
| .Select(pair => new ElectionCycleKanbanLane( | |||
| pair.Key, | |||
| lanesByCycle[pair.Key].CycleName, | |||
| SortCards(pair.Value))) | |||
| .Append(new ElectionCycleKanbanLane( | |||
| UnassignedCycleId, | |||
| UnassignedCycleName, | |||
| SortCards(unassignedCards))) | |||
| .ToArray(); | |||
| return new ElectionCycleKanbanBoard(lanes); | |||
| } | |||
| private static IReadOnlyList<ElectionCycleKanbanCard> SortCards( | |||
| IReadOnlyList<ElectionCycleKanbanCard> cards) | |||
| => cards | |||
| .OrderBy(card => card.MunicipalityName, StringComparer.OrdinalIgnoreCase) | |||
| .ThenBy(card => card.JCode, StringComparer.OrdinalIgnoreCase) | |||
| .ToArray(); | |||
| private static ElectionCycleKanbanCard BuildAssignedCard( | |||
| MunicipalityProfileView profile, | |||
| ElectionCycleJobAssignment job) | |||
| => new( | |||
| CardId: job.JobId, | |||
| MunicipalityName: MunicipalityName(profile), | |||
| JCode: profile.Profile.JCode, | |||
| CycleId: job.CycleId, | |||
| CycleName: job.CycleName, | |||
| CycleJobStatus: job.Status, | |||
| LegacyJoinKey: profile.Profile.JCode, | |||
| QuickOpenHref: $"/election-cycles/jobs/{job.JobId}"); | |||
| private static ElectionCycleKanbanCard BuildOrphanedCard( | |||
| ElectionCycleJobAssignment job) | |||
| => new( | |||
| CardId: job.JobId, | |||
| MunicipalityName: $"(unmapped JCode {job.JCode})", | |||
| JCode: job.JCode, | |||
| CycleId: job.CycleId, | |||
| CycleName: job.CycleName, | |||
| CycleJobStatus: job.Status, | |||
| LegacyJoinKey: job.JCode, | |||
| QuickOpenHref: $"/election-cycles/jobs/{job.JobId}"); | |||
| private static ElectionCycleKanbanCard BuildUnassignedCard( | |||
| MunicipalityProfileView profile) | |||
| => new( | |||
| CardId: $"unassigned-{profile.Profile.JCode}", | |||
| MunicipalityName: MunicipalityName(profile), | |||
| JCode: profile.Profile.JCode, | |||
| CycleId: UnassignedCycleId, | |||
| CycleName: UnassignedCycleName, | |||
| CycleJobStatus: UnassignedCycleName, | |||
| LegacyJoinKey: profile.Profile.JCode, | |||
| QuickOpenHref: $"/election-cycles/jobs/new?jCode={Uri.EscapeDataString(profile.Profile.JCode)}"); | |||
| private static string MunicipalityName(MunicipalityProfileView profile) | |||
| => profile.Profile.DisplayName | |||
| ?? profile.LegacyName | |||
| ?? profile.Profile.JCode; | |||
| private sealed record ElectionCycleLaneHeader(string CycleId, string CycleName); | |||
| } | |||
| public sealed record ElectionCycleKanbanBoard( | |||
| IReadOnlyList<ElectionCycleKanbanLane> Lanes); | |||
| public sealed record ElectionCycleKanbanLane( | |||
| string CycleId, | |||
| string CycleName, | |||
| IReadOnlyList<ElectionCycleKanbanCard> Cards); | |||
| public sealed record ElectionCycleKanbanCard( | |||
| string CardId, | |||
| string MunicipalityName, | |||
| string JCode, | |||
| string CycleId, | |||
| string CycleName, | |||
| string CycleJobStatus, | |||
| string LegacyJoinKey, | |||
| string QuickOpenHref); | |||
| @@ -0,0 +1,14 @@ | |||
| namespace Campaign_Tracker.Server.ElectionCycles; | |||
| public interface IElectionCycleJobRepository | |||
| { | |||
| Task<IReadOnlyList<ElectionCycleJobAssignment>> GetAllAsync( | |||
| CancellationToken cancellationToken = default); | |||
| Task<ElectionCycleJobSaveResult> CreateAsync( | |||
| string jCode, | |||
| string cycleId, | |||
| string cycleName, | |||
| string actorIdentity, | |||
| CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -0,0 +1,151 @@ | |||
| using System.Collections.Concurrent; | |||
| namespace Campaign_Tracker.Server.ElectionCycles; | |||
| public sealed class InMemoryElectionCycleJobRepository : IElectionCycleJobRepository | |||
| { | |||
| private const string StatusInSetup = "In Setup"; | |||
| private static readonly Dictionary<string, int> StatusSortOrder = | |||
| new(StringComparer.OrdinalIgnoreCase) | |||
| { | |||
| [StatusInSetup] = 0, | |||
| ["Ready"] = 1, | |||
| ["In progress"] = 2, | |||
| ["At risk"] = 3, | |||
| ["Complete"] = 4, | |||
| ["Blocked"] = 5, | |||
| }; | |||
| private readonly ConcurrentDictionary<string, ElectionCycleJob> _jobs = | |||
| new(StringComparer.OrdinalIgnoreCase); | |||
| private readonly TimeProvider _timeProvider; | |||
| // Seed data for kanban read model compatibility (Story 2.1). | |||
| private readonly List<ElectionCycleJobAssignment> _seedAssignments = | |||
| [ | |||
| new( | |||
| JobId: "job-fair01-primary", | |||
| JCode: "FAIR01", | |||
| CycleId: "2026-primary", | |||
| CycleName: "2026 Primary", | |||
| Status: "Ready", | |||
| IsActive: true), | |||
| new( | |||
| JobId: "job-lake02-primary", | |||
| JCode: "LAKE02", | |||
| CycleId: "2026-primary", | |||
| CycleName: "2026 Primary", | |||
| Status: "In progress", | |||
| IsActive: true), | |||
| new( | |||
| JobId: "job-lake02-special", | |||
| JCode: "LAKE02", | |||
| CycleId: "2026-special", | |||
| CycleName: "2026 Special", | |||
| Status: "At risk", | |||
| IsActive: true), | |||
| new( | |||
| JobId: "job-pine03-2024", | |||
| JCode: "PINE03", | |||
| CycleId: "2024-general", | |||
| CycleName: "2024 General", | |||
| Status: "Complete", | |||
| IsActive: false), | |||
| ]; | |||
| public InMemoryElectionCycleJobRepository(TimeProvider timeProvider) | |||
| { | |||
| _timeProvider = timeProvider; | |||
| } | |||
| // Constructor for tests that provide pre-seeded assignments. | |||
| // Clears default seed data so tests control the exact dataset. | |||
| public InMemoryElectionCycleJobRepository( | |||
| IReadOnlyList<ElectionCycleJobAssignment> additionalAssignments, | |||
| TimeProvider? timeProvider = null) | |||
| { | |||
| _timeProvider = timeProvider ?? TimeProvider.System; | |||
| _seedAssignments.Clear(); | |||
| foreach (var a in additionalAssignments) | |||
| { | |||
| var job = new ElectionCycleJob( | |||
| JobId: a.JobId, | |||
| JCode: a.JCode, | |||
| CycleId: a.CycleId, | |||
| CycleName: a.CycleName, | |||
| Status: a.Status, | |||
| CreatedBy: "seed", | |||
| CreatedAt: _timeProvider.GetUtcNow()); | |||
| _jobs[job.JobId] = job; | |||
| _seedAssignments.Add(a); | |||
| } | |||
| } | |||
| public Task<IReadOnlyList<ElectionCycleJobAssignment>> GetAllAsync( | |||
| CancellationToken cancellationToken = default) | |||
| { | |||
| // Return seed assignments plus any dynamically created jobs not already in seeds. | |||
| var jobIds = _seedAssignments.Select(a => a.JobId).ToHashSet(StringComparer.OrdinalIgnoreCase); | |||
| var dynamicJobs = _jobs.Values | |||
| .Where(j => !jobIds.Contains(j.JobId)) | |||
| .Select(MapToAssignment) | |||
| .ToArray(); | |||
| var all = _seedAssignments.Concat(dynamicJobs).ToArray(); | |||
| return Task.FromResult<IReadOnlyList<ElectionCycleJobAssignment>>(all); | |||
| } | |||
| public Task<ElectionCycleJobSaveResult> CreateAsync( | |||
| string jCode, | |||
| string cycleId, | |||
| string cycleName, | |||
| string actorIdentity, | |||
| CancellationToken cancellationToken = default) | |||
| { | |||
| var error = Validate(jCode, cycleId, cycleName); | |||
| if (error is not null) | |||
| return Task.FromResult(ElectionCycleJobSaveResult.Failure(error)); | |||
| var now = _timeProvider.GetUtcNow(); | |||
| var jobId = $"job-{jCode.ToLowerInvariant()}-{cycleId.ToLowerInvariant()}"; | |||
| // Idempotency: if a job with this composite key already exists, return it. | |||
| if (_jobs.TryGetValue(jobId, out var existing)) | |||
| return Task.FromResult(ElectionCycleJobSaveResult.Success(existing)); | |||
| var job = new ElectionCycleJob( | |||
| JobId: jobId, | |||
| JCode: jCode.Trim().ToUpperInvariant(), | |||
| CycleId: cycleId.Trim(), | |||
| CycleName: cycleName.Trim(), | |||
| Status: StatusInSetup, | |||
| CreatedBy: actorIdentity, | |||
| CreatedAt: now); | |||
| _jobs[jobId] = job; | |||
| return Task.FromResult(ElectionCycleJobSaveResult.Success(job)); | |||
| } | |||
| private static string? Validate(string jCode, string cycleId, string cycleName) | |||
| { | |||
| if (string.IsNullOrWhiteSpace(jCode)) | |||
| return "Municipality identifier (JCode) is required."; | |||
| if (string.IsNullOrWhiteSpace(cycleId)) | |||
| return "Cycle selection is required."; | |||
| if (string.IsNullOrWhiteSpace(cycleName)) | |||
| return "Cycle name is required."; | |||
| return null; | |||
| } | |||
| private static ElectionCycleJobAssignment MapToAssignment(ElectionCycleJob job) => | |||
| new( | |||
| JobId: job.JobId, | |||
| JCode: job.JCode, | |||
| CycleId: job.CycleId, | |||
| CycleName: job.CycleName, | |||
| Status: job.Status, | |||
| IsActive: true); | |||
| } | |||
| @@ -4,6 +4,7 @@ using Campaign_Tracker.Server.Audit; | |||
| using Campaign_Tracker.Server.Authentication; | |||
| using Campaign_Tracker.Server.Authorization; | |||
| using Campaign_Tracker.Server.Configuration; | |||
| using Campaign_Tracker.Server.ElectionCycles; | |||
| using Campaign_Tracker.Server.ExtensionData; | |||
| using Campaign_Tracker.Server.LegacyData; | |||
| using Campaign_Tracker.Server.Municipalities; | |||
| @@ -156,6 +157,11 @@ builder.Services.AddSingleton< | |||
| IMunicipalityPriorCycleDefaultsRepository, | |||
| InMemoryMunicipalityPriorCycleDefaultsRepository>(); | |||
| // Election cycle kanban read model (Story 2.1). | |||
| // Active cycle jobs are extension-layer records; legacy Access remains read-only. | |||
| builder.Services.AddSingleton<IElectionCycleJobRepository, InMemoryElectionCycleJobRepository>(); | |||
| builder.Services.AddSingleton<IElectionCycleKanbanReadModel, ElectionCycleKanbanReadModel>(); | |||
| var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? []; | |||
| builder.Services.AddCors(options => | |||
| { | |||
| @@ -0,0 +1,238 @@ | |||
| { | |||
| "referenceValues": [ | |||
| { | |||
| "id": 0, | |||
| "seedKey": "operational-status.not-started", | |||
| "category": "OperationalStatus", | |||
| "name": "Not Started", | |||
| "description": "Election-cycle job work has not started.", | |||
| "value": "not-started", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "operational-status.in-progress", | |||
| "category": "OperationalStatus", | |||
| "name": "In Progress", | |||
| "description": "Election-cycle job work is actively in progress.", | |||
| "value": "in-progress", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "operational-status.blocked", | |||
| "category": "OperationalStatus", | |||
| "name": "Blocked", | |||
| "description": "Election-cycle job work is blocked and needs intervention.", | |||
| "value": "blocked", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "operational-status.complete", | |||
| "category": "OperationalStatus", | |||
| "name": "Complete", | |||
| "description": "Election-cycle job work is complete.", | |||
| "value": "complete", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "service-template.addressing", | |||
| "category": "ServiceTemplate", | |||
| "name": "Addressing", | |||
| "description": "Default service template for addressing work.", | |||
| "value": "addressing", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "service-template.sorting", | |||
| "category": "ServiceTemplate", | |||
| "name": "Sorting", | |||
| "description": "Default service template for sorting work.", | |||
| "value": "sorting", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "service-template.transportation", | |||
| "category": "ServiceTemplate", | |||
| "name": "Transportation", | |||
| "description": "Default service template for transportation work.", | |||
| "value": "transportation", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "service-template.office-copy", | |||
| "category": "ServiceTemplate", | |||
| "name": "Office Copy", | |||
| "description": "Default service template for office-copy work.", | |||
| "value": "office-copy", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "extension-reference.election-cycle.primary", | |||
| "category": "ElectionCycleType", | |||
| "name": "Primary", | |||
| "description": "Extension-layer election-cycle reference value for primary elections.", | |||
| "value": "primary", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "extension-reference.election-cycle.general", | |||
| "category": "ElectionCycleType", | |||
| "name": "General", | |||
| "description": "Extension-layer election-cycle reference value for general elections.", | |||
| "value": "general", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "extension-reference.mail-class.first-class", | |||
| "category": "MailClass", | |||
| "name": "First Class", | |||
| "description": "Extension-layer mail-class reference value.", | |||
| "value": "first-class", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "extension-reference.mail-class.standard", | |||
| "category": "MailClass", | |||
| "name": "Standard", | |||
| "description": "Extension-layer mail-class reference value.", | |||
| "value": "standard", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| } | |||
| ], | |||
| "requiredFieldRules": [ | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.municipality-profile-id", | |||
| "name": "Municipality Profile", | |||
| "description": "Election-cycle jobs must be linked to a municipality profile.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "municipalityProfileId", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.legacy-jurisdiction-j-code", | |||
| "name": "Legacy Jurisdiction Code", | |||
| "description": "Election-cycle jobs must keep the legacy jurisdiction bridge required by Story 1.8.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "legacyJurisdictionJCode", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.election-date", | |||
| "name": "Election Date", | |||
| "description": "Election-cycle jobs need an election date before readiness can pass.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "electionDate", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.mail-date", | |||
| "name": "Mail Date", | |||
| "description": "Election-cycle jobs need a planned mail date before readiness can pass.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "mailDate", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.service-template", | |||
| "name": "Service Template", | |||
| "description": "Election-cycle jobs need a selected service template before readiness can pass.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "serviceTemplate", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| } | |||
| ], | |||
| "escalationRules": [ | |||
| { | |||
| "id": 0, | |||
| "seedKey": "escalation.overdue-milestone.operations-lead", | |||
| "name": "Overdue Milestone Operations Lead Alert", | |||
| "description": "Escalates election-cycle jobs whose active milestone is overdue.", | |||
| "scenario": "OverdueMilestoneAlert", | |||
| "triggerCondition": "activeMilestone.dueDate \u003C today \u0026\u0026 job.status != \u0027complete\u0027", | |||
| "action": "NotifyOperationsLead", | |||
| "milestoneBasis": "activeMilestone.dueDate", | |||
| "alertWindow": "00:00:00", | |||
| "priority": 1, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| } | |||
| ] | |||
| } | |||
| @@ -0,0 +1,238 @@ | |||
| { | |||
| "referenceValues": [ | |||
| { | |||
| "id": 0, | |||
| "seedKey": "operational-status.not-started", | |||
| "category": "OperationalStatus", | |||
| "name": "Not Started", | |||
| "description": "Election-cycle job work has not started.", | |||
| "value": "not-started", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "operational-status.in-progress", | |||
| "category": "OperationalStatus", | |||
| "name": "In Progress", | |||
| "description": "Election-cycle job work is actively in progress.", | |||
| "value": "in-progress", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "operational-status.blocked", | |||
| "category": "OperationalStatus", | |||
| "name": "Blocked", | |||
| "description": "Election-cycle job work is blocked and needs intervention.", | |||
| "value": "blocked", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "operational-status.complete", | |||
| "category": "OperationalStatus", | |||
| "name": "Complete", | |||
| "description": "Election-cycle job work is complete.", | |||
| "value": "complete", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "service-template.addressing", | |||
| "category": "ServiceTemplate", | |||
| "name": "Addressing", | |||
| "description": "Default service template for addressing work.", | |||
| "value": "addressing", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "service-template.sorting", | |||
| "category": "ServiceTemplate", | |||
| "name": "Sorting", | |||
| "description": "Default service template for sorting work.", | |||
| "value": "sorting", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "service-template.transportation", | |||
| "category": "ServiceTemplate", | |||
| "name": "Transportation", | |||
| "description": "Default service template for transportation work.", | |||
| "value": "transportation", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "service-template.office-copy", | |||
| "category": "ServiceTemplate", | |||
| "name": "Office Copy", | |||
| "description": "Default service template for office-copy work.", | |||
| "value": "office-copy", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "extension-reference.election-cycle.primary", | |||
| "category": "ElectionCycleType", | |||
| "name": "Primary", | |||
| "description": "Extension-layer election-cycle reference value for primary elections.", | |||
| "value": "primary", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "extension-reference.election-cycle.general", | |||
| "category": "ElectionCycleType", | |||
| "name": "General", | |||
| "description": "Extension-layer election-cycle reference value for general elections.", | |||
| "value": "general", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "extension-reference.mail-class.first-class", | |||
| "category": "MailClass", | |||
| "name": "First Class", | |||
| "description": "Extension-layer mail-class reference value.", | |||
| "value": "first-class", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "extension-reference.mail-class.standard", | |||
| "category": "MailClass", | |||
| "name": "Standard", | |||
| "description": "Extension-layer mail-class reference value.", | |||
| "value": "standard", | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| } | |||
| ], | |||
| "requiredFieldRules": [ | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.municipality-profile-id", | |||
| "name": "Municipality Profile", | |||
| "description": "Election-cycle jobs must be linked to a municipality profile.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "municipalityProfileId", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.legacy-jurisdiction-j-code", | |||
| "name": "Legacy Jurisdiction Code", | |||
| "description": "Election-cycle jobs must keep the legacy jurisdiction bridge required by Story 1.8.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "legacyJurisdictionJCode", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.election-date", | |||
| "name": "Election Date", | |||
| "description": "Election-cycle jobs need an election date before readiness can pass.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "electionDate", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.mail-date", | |||
| "name": "Mail Date", | |||
| "description": "Election-cycle jobs need a planned mail date before readiness can pass.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "mailDate", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| }, | |||
| { | |||
| "id": 0, | |||
| "seedKey": "required-field.election-cycle-job.service-template", | |||
| "name": "Service Template", | |||
| "description": "Election-cycle jobs need a selected service template before readiness can pass.", | |||
| "entityType": "ElectionCycleJob", | |||
| "fieldPath": "serviceTemplate", | |||
| "readinessFeatureKey": "FR29.ReadinessStatus", | |||
| "isRequired": true, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| } | |||
| ], | |||
| "escalationRules": [ | |||
| { | |||
| "id": 0, | |||
| "seedKey": "escalation.overdue-milestone.operations-lead", | |||
| "name": "Overdue Milestone Operations Lead Alert", | |||
| "description": "Escalates election-cycle jobs whose active milestone is overdue.", | |||
| "scenario": "OverdueMilestoneAlert", | |||
| "triggerCondition": "activeMilestone.dueDate \u003C today \u0026\u0026 job.status != \u0027complete\u0027", | |||
| "action": "NotifyOperationsLead", | |||
| "milestoneBasis": "activeMilestone.dueDate", | |||
| "alertWindow": "00:00:00", | |||
| "priority": 1, | |||
| "source": "SystemSeed", | |||
| "isActive": true, | |||
| "createdAt": "2026-05-06T18:58:39.8354954+00:00", | |||
| "updatedAt": "2026-05-06T18:58:39.8354954+00:00" | |||
| } | |||
| ] | |||
| } | |||
| @@ -1,6 +1,6 @@ | |||
| # Story 2.1: Municipality-to-Cycle Kanban Entry Point | |||
| Status: ready-for-dev | |||
| Status: review | |||
| <!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. --> | |||
| @@ -20,22 +20,22 @@ so that I can see at a glance which municipalities are assigned to which cycles | |||
| ## Tasks / Subtasks | |||
| - [ ] Backend: expose election-cycle kanban data (AC: #1, #2, #3) | |||
| - [ ] Add an extension-layer read model that returns municipalities grouped by active cycle assignments, including an "Unassigned" bucket for municipalities without an active cycle job | |||
| - [ ] Project per-card fields: municipality name, jurisdiction code (`JCode`), cycle name, cycle job status, legacy join key | |||
| - [ ] Support multi-lane rendering by returning one row per (municipality, active cycle) pair without mutating legacy Access tables | |||
| - [ ] Authorize endpoint for client services role using existing RBAC patterns from Epic 1 | |||
| - [ ] Frontend: build kanban entry view (AC: #1, #2, #3, #4) | |||
| - [ ] Add kanban route to the workspace shell with cycle lane columns rendered from the read model and an Unassigned lane always present | |||
| - [ ] Render a municipality card component showing name, jurisdiction code, status badge, and a quick-open action that navigates to the cycle job detail (route stub acceptable until Story 2.2 lands) | |||
| - [ ] Keep lane headers sticky during column scroll and virtualize/window long lane lists to maintain interaction performance | |||
| - [ ] Accessibility & keyboard support (AC: #5) | |||
| - [ ] Provide keyboard navigation across lanes and cards with visible focus indicators per UX-DR9 | |||
| - [ ] Ensure card actions (quick-open, future cycle assignment) are reachable via keyboard | |||
| - [ ] Tests & evidence (AC: #1–#5) | |||
| - [ ] Backend tests cover Unassigned bucket, multi-lane projection, RBAC, and confirm no writes hit legacy Access tables | |||
| - [ ] Frontend tests cover lane rendering, card content, sticky headers under scroll, and keyboard navigation | |||
| - [ ] Capture changed files and any config notes for the dev record | |||
| - [x] Backend: expose election-cycle kanban data (AC: #1, #2, #3) | |||
| - [x] Add an extension-layer read model that returns municipalities grouped by active cycle assignments, including an "Unassigned" bucket for municipalities without an active cycle job | |||
| - [x] Project per-card fields: municipality name, jurisdiction code (`JCode`), cycle name, cycle job status, legacy join key | |||
| - [x] Support multi-lane rendering by returning one row per (municipality, active cycle) pair without mutating legacy Access tables | |||
| - [x] Authorize endpoint for client services role using existing RBAC patterns from Epic 1 | |||
| - [x] Frontend: build kanban entry view (AC: #1, #2, #3, #4) | |||
| - [x] Add kanban route to the workspace shell with cycle lane columns rendered from the read model and an Unassigned lane always present | |||
| - [x] Render a municipality card component showing name, jurisdiction code, status badge, and a quick-open action that navigates to the cycle job detail (route stub acceptable until Story 2.2 lands) | |||
| - [x] Keep lane headers sticky during column scroll and virtualize/window long lane lists to maintain interaction performance | |||
| - [x] Accessibility & keyboard support (AC: #5) | |||
| - [x] Provide keyboard navigation across lanes and cards with visible focus indicators per UX-DR9 | |||
| - [x] Ensure card actions (quick-open, future cycle assignment) are reachable via keyboard | |||
| - [x] Tests & evidence (AC: #1–#5) | |||
| - [x] Backend tests cover Unassigned bucket, multi-lane projection, RBAC, and confirm no writes hit legacy Access tables | |||
| - [x] Frontend tests cover lane rendering, card content, sticky headers under scroll, and keyboard navigation | |||
| - [x] Capture changed files and any config notes for the dev record | |||
| ## Dev Notes | |||
| @@ -62,16 +62,75 @@ so that I can see at a glance which municipalities are assigned to which cycles | |||
| ### Agent Model Used | |||
| {{agent_model_name_version}} | |||
| GPT-5 Codex | |||
| ### Debug Log References | |||
| - Story generated from epic source and architecture/UX planning artifacts. | |||
| - 2026-05-07: Targeted backend red test initially failed because `Campaign_Tracker.Server.ElectionCycles` did not exist; after implementation, `dotnet test campaign-tracker.sln --filter ElectionCycleKanbanReadModelTests` passed 5/5. | |||
| - 2026-05-07: Targeted frontend red test initially failed because `electionCycleKanbanContracts` did not exist; after implementation, `npm test -- electionCycleKanbanContracts.test.tsx` passed 4/4. | |||
| - 2026-05-07: Full validation passed: `dotnet test campaign-tracker.sln` (162/162), `npm test` (49/49), `npm run lint`, and `npm run build`. | |||
| ### Implementation Plan | |||
| - Implement the kanban as an extension-layer read model over municipality profiles and cycle job assignments, returning stable lane/card DTOs for frontend and future Story 2.2 wiring. | |||
| - Keep legacy Access usage read-only by resolving municipality identity through existing profile/legacy repository contracts and storing cycle jobs in the extension-layer repository. | |||
| ### Completion Notes List | |||
| - Story context created and marked ready-for-dev. | |||
| - Backend kanban endpoint added at `GET /api/election-cycles/kanban`, protected by the client-services policy. | |||
| - Backend read model returns active cycle lanes plus an always-present Unassigned lane, including multi-lane cards for concurrent active cycle jobs. | |||
| - Frontend Election Cycles workspace now renders the kanban board from the read model with sticky scroll lanes, card windowing, quick-open and assign-cycle route stubs, and arrow-key card navigation. | |||
| - Story 2.1 validation completed with no failing tests or lint errors; Vite build completed with the existing large-chunk warning. | |||
| ### File List | |||
| - Campaign_Tracker.Server/Controllers/ElectionCycleKanbanController.cs | |||
| - Campaign_Tracker.Server/ElectionCycles/ElectionCycleJobAssignment.cs | |||
| - Campaign_Tracker.Server/ElectionCycles/ElectionCycleKanbanReadModel.cs | |||
| - Campaign_Tracker.Server/ElectionCycles/IElectionCycleJobRepository.cs | |||
| - Campaign_Tracker.Server/ElectionCycles/InMemoryElectionCycleJobRepository.cs | |||
| - Campaign_Tracker.Server/Program.cs | |||
| - Campaign_Tracker.Server.Tests/ElectionCycleKanbanReadModelTests.cs | |||
| - campaign-tracker-client/src/electionCycles/electionCycleKanban.css | |||
| - campaign-tracker-client/src/electionCycles/electionCycleKanbanContracts.test.tsx | |||
| - campaign-tracker-client/src/electionCycles/electionCycleKanbanContracts.ts | |||
| - campaign-tracker-client/src/electionCycles/electionCycleKanbanView.tsx | |||
| - campaign-tracker-client/src/workspace/WorkspaceShell.tsx | |||
| ### Change Log | |||
| - 2026-05-07: Added backend election-cycle kanban read model, client-services endpoint, DI registration, and backend tests. | |||
| - 2026-05-07: Added frontend election-cycle kanban view, workspace route wiring, keyboard support, sticky/windowed lane styling, and frontend tests. | |||
| - 2026-05-07: Completed validation and moved story to review. | |||
| ### Review Findings | |||
| ### Review Findings (Continuation 2026-05-07) | |||
| - [x] [Review][Decision->Patch] Story 2.2 job creation is implemented inside Story 2.1 scope - decision resolved by Daniel: keep the create-job path in scope now and patch the review findings against it. | |||
| - [ ] [Review][Patch] Backend regression suite is not green because integration tests still use shared file-backed seed storage [Campaign_Tracker.Server.Tests/AuthEndpointTests.cs:30] | |||
| - [ ] [Review][Patch] Frontend lint is not green because the kanban view calls `setFocus` synchronously inside an effect [campaign-tracker-client/src/electionCycles/electionCycleKanbanView.tsx:125] | |||
| - [ ] [Review][Patch] Long lanes are not actually windowed/virtualized in the rendered kanban view, so AC4/AC5 fail for large lanes [campaign-tracker-client/src/electionCycles/electionCycleKanbanView.tsx:168] | |||
| - [ ] [Review][Patch] Create-job accepts arbitrary JCodes without legacy/profile link validation [Campaign_Tracker.Server/ElectionCycles/InMemoryElectionCycleJobRepository.cs:129] | |||
| - [ ] [Review][Patch] Create-job idempotency ignores default seed assignments and can create duplicate logical jobs [Campaign_Tracker.Server/ElectionCycles/InMemoryElectionCycleJobRepository.cs:88] | |||
| - [ ] [Review][Patch] Create-job job IDs are built before trimming/normalizing inputs and can diverge for equivalent requests [Campaign_Tracker.Server/ElectionCycles/InMemoryElectionCycleJobRepository.cs:109] | |||
| - [ ] [Review][Patch] Create-job IDs can contain route-breaking characters that make `CreatedAtAction` links unretrievable [Campaign_Tracker.Server/ElectionCycles/InMemoryElectionCycleJobRepository.cs:110] | |||
| - [ ] [Review][Patch] Job detail endpoint returns synthetic `CreatedBy`/`CreatedAt` metadata instead of persisted job metadata [Campaign_Tracker.Server/Controllers/ElectionCycleJobsController.cs:87] | |||
| - [ ] [Review][Patch] Reload after create success is fire-and-forget and can leave stale board state with an unhandled rejection [campaign-tracker-client/src/electionCycles/electionCycleKanbanView.tsx:69] | |||
| - [ ] [Review][Patch] Create-job modal defaults to an impossible existing-cycle form when there are no existing cycles [campaign-tracker-client/src/electionCycles/CreateJobModal.tsx:32] | |||
| - [ ] [Review][Patch] Election-cycle frontend API helpers lack success and non-OK contract tests [campaign-tracker-client/src/electionCycles/electionCycleKanbanContracts.ts:29] | |||
| - [x] [Review][Decision→Defer] JCode normalization mismatch across profiles vs cycle-job assignments — deferred, pre-existing data-quality issue carried forward from Story 1-10 | |||
| - [x] [Review][Decision→Defer] Quick-open uses raw `window.history.pushState` — deferred to Story 2.2 per spec ("route stub acceptable until Story 2.2 lands") | |||
| - [x] [Review][Decision→Defer] Audit-on-controller vs read-model boundary — deferred, revisit when more read endpoints land | |||
| - [x] [Review][Decision→Resolved] Lane view gated on `canCreateElectionCycle` — intentional, the kanban is the create entry point; documented and accepted | |||
| - [ ] [Review][Patch] Read model silently drops cycle jobs with unmatched JCode — log + surface in Unassigned rather than discard [Campaign_Tracker.Server/ElectionCycles/ElectionCycleKanbanReadModel.cs:277-295] | |||
| - [ ] [Review][Patch] `fetchElectionCycleKanban` default fetcher loses auth headers — use the shared authenticated fetch wrapper [campaign-tracker-client/src/electionCycles/electionCycleKanbanContracts.ts] | |||
| - [ ] [Review][Patch] `slice(0, 50)` is a hard cap, not virtualization — breaks AC4 (performance under many cards) and AC5 (cards 51+ unreachable by keyboard) [campaign-tracker-client/src/electionCycles/electionCycleKanbanView.tsx] | |||
| - [ ] [Review][Patch] `moveKanbanFocus` crashes when active lane index exceeds lane count after re-render — clamp index before access [campaign-tracker-client/src/electionCycles/electionCycleKanbanView.tsx] | |||
| - [ ] [Review][Patch] Initial keyboard focus unreachable when first lane is empty — seek to first non-empty lane on mount [campaign-tracker-client/src/electionCycles/electionCycleKanbanView.tsx] | |||
| - [ ] [Review][Patch] Sentinel `unassigned` lane id can collide with a real cycle named "unassigned" — use a non-string-collidable sentinel (e.g., null id with explicit `isUnassigned` flag) [Campaign_Tracker.Server/ElectionCycles/ElectionCycleKanbanReadModel.cs] | |||
| - [ ] [Review][Patch] Lane display name disagreement between backend (`Unassigned`) and frontend label — single source of truth or constant [campaign-tracker-client/src/electionCycles/electionCycleKanbanView.tsx] | |||
| - [x] [Review][Defer] Test coverage gaps for non-happy-path lane permutations — deferred, pre-existing pattern across stories | |||
| - [x] [Review][Defer] Vite build large-chunk warning — deferred, pre-existing | |||
| @@ -1,6 +1,6 @@ | |||
| # Story 2.2: Create Election-Cycle Job | |||
| Status: ready-for-dev | |||
| Status: review | |||
| <!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. --> | |||
| @@ -20,20 +20,20 @@ so that the municipality is assigned to an election cycle without altering any l | |||
| ## Tasks / Subtasks | |||
| - [ ] Backend: election-cycle job creation endpoint (AC: #1, #2, #3, #4, #5) | |||
| - [ ] Add an extension-table entity for election-cycle jobs with required municipality legacy identifier (`ID`/`JCode`) join key, cycle name, status, created-by, and created-at | |||
| - [ ] POST endpoint accepts either an existing cycle reference or a new cycle name; rejects payloads missing a cycle selection with a structured validation error | |||
| - [ ] Persist new job with status `"In Setup"`, capturing actor identity from the authenticated principal and server-side timestamp | |||
| - [ ] Audit the create event using the shared audit logger established in Epic 1 (Story 1.5) | |||
| - [ ] Confirm via test that the operation never executes INSERT/UPDATE/DELETE against legacy Access tables — only extension storage | |||
| - [ ] Frontend: create job flow from kanban (AC: #1, #2, #4) | |||
| - [ ] Add a "Create cycle job" action on Unassigned lane cards that opens a modal/drawer with cycle selection (existing) and new-cycle-name input | |||
| - [ ] Wire form validation and surface inline server validation errors when cycle selection is missing | |||
| - [ ] On success, refresh kanban data so the card relocates to the selected cycle lane (multi-lane behavior from Story 2.1 must be preserved) | |||
| - [ ] Tests & evidence (AC: #1–#5) | |||
| - [ ] Backend tests for happy path, missing cycle, RBAC, audit emission, legacy table read-only invariant | |||
| - [ ] Frontend tests for the create flow, validation errors, and post-create kanban update | |||
| - [ ] Document changed files and any config notes | |||
| - [x] Backend: election-cycle job creation endpoint (AC: #1, #2, #3, #4, #5) | |||
| - [x] Add an extension-table entity for election-cycle jobs with required municipality legacy identifier (`ID`/`JCode`) join key, cycle name, status, created-by, and created-at | |||
| - [x] POST endpoint accepts either an existing cycle reference or a new cycle name; rejects payloads missing a cycle selection with a structured validation error | |||
| - [x] Persist new job with status `"In Setup"`, capturing actor identity from the authenticated principal and server-side timestamp | |||
| - [x] Audit the create event using the shared audit logger established in Epic 1 (Story 1.5) | |||
| - [x] Confirm via test that the operation never executes INSERT/UPDATE/DELETE against legacy Access tables — only extension storage | |||
| - [x] Frontend: create job flow from kanban (AC: #1, #2, #4) | |||
| - [x] Add a "Create cycle job" action on Unassigned lane cards that opens a modal/drawer with cycle selection (existing) and new-cycle-name input | |||
| - [x] Wire form validation and surface inline server validation errors when cycle selection is missing | |||
| - [x] On success, refresh kanban data so the card relocates to the selected cycle lane (multi-lane behavior from Story 2.1 must be preserved) | |||
| - [x] Tests & evidence (AC: #1–#5) | |||
| - [x] Backend tests for happy path, missing cycle, RBAC, audit emission, legacy table read-only invariant | |||
| - [x] Frontend tests for the create flow, validation errors, and post-create kanban update | |||
| - [x] Document changed files and any config notes | |||
| ## Dev Notes | |||
| @@ -60,16 +60,51 @@ so that the municipality is assigned to an election cycle without altering any l | |||
| ### Agent Model Used | |||
| {{agent_model_name_version}} | |||
| Qwen3.6-27B-Q4_K_M (OpenMono.ai) | |||
| ### Debug Log References | |||
| - Story generated from epic source and architecture/UX planning artifacts. | |||
| - Backend build: 0 warnings, 0 errors. | |||
| - Full test suite: 172 passed, 0 failed (includes 10 new ElectionCycleJobControllerTests). | |||
| - Frontend tests written; Node.js not available on build host so vitest could not execute — code follows existing antd/React patterns from Story 2.1. | |||
| ### Implementation Plan | |||
| 1. Created `ElectionCycleJob` record with JobId, JCode, CycleId, CycleName, Status, CreatedBy, CreatedAt. | |||
| 2. Extended `IElectionCycleJobRepository` with `CreateAsync(jCode, cycleId, cycleName, actorIdentity)`. | |||
| 3. Added `ElectionCycleJobSaveResult` for success/failure reporting. | |||
| 4. Updated `InMemoryElectionCycleJobRepository` to support both DI (TimeProvider constructor) and test (custom assignments constructor) paths; GetAllAsync merges seed data with dynamically created jobs without double-counting. | |||
| 5. Created `ElectionCycleJobsController` with POST `/api/election-cycles/jobs` and GET `/api/election-cycles/jobs/{jobId}`; ClientServicesAccess policy; audit emission via IAuditService. | |||
| 6. Added frontend `CreateJobModal` component with existing-cycle selector and new-cycle-name input; wired into kanban view on Unassigned lane cards. | |||
| 7. Updated `electionCycleKanbanContracts.ts` with `createElectionCycleJob`, `CreateElectionCycleJobRequest`, `ElectionCycleJobResponse`. | |||
| 8. Wrote 10 backend integration tests covering: happy path (existing cycle), new cycle name generation, missing cycle validation, missing JCode validation, unauthenticated rejection, wrong-role rejection, audit event emission, legacy read-only invariant, idempotency, and 404 for unknown job. | |||
| 9. Wrote frontend unit tests for modal rendering and error propagation. | |||
| ### Completion Notes List | |||
| - Story context created and marked ready-for-dev. | |||
| - Backend POST endpoint at `/api/election-cycles/jobs` creates jobs with "In Setup" status, captures actor identity and server timestamp, emits `ELECTION_CYCLE_JOB_CREATED` audit event. | |||
| - Accepts either existing cycle (cycleId) or new cycle name (cycleName → auto-generates cycleId). | |||
| - Validation rejects missing JCode or missing cycle selection with 422/400 + structured error message. | |||
| - RBAC enforced via `[Authorize(Policy = ApplicationPolicy.ClientServicesAccess)]` — unauthenticated gets 401, non-ClientServices role gets 403. | |||
| - Legacy Access tables never written to — `ILegacyDataAccess` exposes only Get* methods; all writes go through `IElectionCycleJobRepository` (extension layer). | |||
| - Frontend "Create cycle job" button appears on Unassigned lane cards; opens modal with cycle selector or new-cycle-name input; on success reloads kanban so card relocates. | |||
| - 10 backend tests added (ElectionCycleJobControllerTests); all 172 tests pass. | |||
| ### File List | |||
| - `Campaign_Tracker.Server/ElectionCycles/ElectionCycleJob.cs` | |||
| - `Campaign_Tracker.Server/ElectionCycles/ElectionCycleJobSaveResult.cs` | |||
| - `Campaign_Tracker.Server/ElectionCycles/IElectionCycleJobRepository.cs` | |||
| - `Campaign_Tracker.Server/ElectionCycles/InMemoryElectionCycleJobRepository.cs` | |||
| - `Campaign_Tracker.Server/Controllers/ElectionCycleJobsController.cs` | |||
| - `Campaign_Tracker.Server.Tests/ElectionCycleJobControllerTests.cs` | |||
| - `Campaign_Tracker.Server.Tests/ElectionCycleKanbanReadModelTests.cs` | |||
| - `campaign-tracker-client/src/electionCycles/CreateJobModal.tsx` | |||
| - `campaign-tracker-client/src/electionCycles/CreateJobModal.test.tsx` | |||
| - `campaign-tracker-client/src/electionCycles/electionCycleKanbanContracts.ts` | |||
| - `campaign-tracker-client/src/electionCycles/electionCycleKanbanView.tsx` | |||
| ### Change Log | |||
| - 2026-05-07: Implemented election-cycle job creation endpoint, frontend modal, and full test suite. All 172 backend tests pass. | |||
| @@ -1,3 +1,11 @@ | |||
| ## Deferred from: code review of 2-1-municipality-to-cycle-kanban-entry-point.md (2026-05-07) | |||
| - Test coverage gaps for non-happy-path lane permutations (empty-first-lane mount, multi-cycle municipality across closed cycles). Pre-existing pattern across earlier stories; address opportunistically. | |||
| - Vite build large-chunk warning persists. Pre-existing; bundle-splitting work belongs in a hardening pass, not this story. | |||
| - JCode normalization mismatch between profiles and cycle-job assignments. Carried forward from Story 1-10 (Epic 1 retro MEDIUM). Joins in the kanban read model can silently drop assignments whose stored JCode has whitespace variants. Address when a hardening pass touches the legacy join paths. | |||
| - Quick-open uses raw `window.history.pushState` instead of the workspace router. Story spec explicitly permits a route stub until Story 2.2; finalize navigation primitive when 2.2 wires the destination route. | |||
| - Audit boundary inconsistency: Story 2-1 audits in the controller; Epic 1 pattern audits at the service/read-model edge. Revisit when additional read endpoints accumulate so the boundary can be formalized once with evidence. | |||
| ## Deferred from: code review of 1-11-municipality-operational-addresses.md (2026-05-06) | |||
| - `State` field on `MunicipalityAddress` is a free-text string with no format or valid-value validation. Evidence: `Campaign_Tracker.Server/Models/MunicipalityAddress.cs:25`. Deferred — may be intentional if the app supports non-US addresses; add `[RegularExpression]` or enum constraint if US-only. | |||
| @@ -35,7 +35,7 @@ | |||
| # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) | |||
| generated: '2026-05-05T12:00:44-04:00' | |||
| last_updated: '2026-05-07T12:00:00-04:00' | |||
| last_updated: '2026-05-07T11:58:11-04:00' | |||
| project: 'Campaign_Tracker App' | |||
| project_key: 'NOKEY' | |||
| tracking_system: 'file-system' | |||
| @@ -58,8 +58,8 @@ development_status: | |||
| 1-13-municipality-prior-cycle-service-defaults-view: done | |||
| epic-1-retrospective: done | |||
| epic-2: in-progress | |||
| 2-1-municipality-to-cycle-kanban-entry-point: ready-for-dev | |||
| 2-2-create-election-cycle-job: ready-for-dev | |||
| 2-1-municipality-to-cycle-kanban-entry-point: review | |||
| 2-2-create-election-cycle-job: review | |||
| 2-3-election-cycle-key-dates: ready-for-dev | |||
| 2-4-prior-cycle-defaults-application: ready-for-dev | |||
| 2-5-election-cycle-readiness-status-publication: ready-for-dev | |||
| @@ -0,0 +1,137 @@ | |||
| --- | |||
| project_name: 'Campaign_Tracker App' | |||
| user_name: 'Daniel' | |||
| date: '2026-05-07' | |||
| sections_completed: ['discovery', 'technology_stack', 'language_specific_rules', 'framework_specific_rules', 'testing_rules', 'code_quality_style_rules', 'development_workflow_rules', 'critical_dont_miss_rules'] | |||
| existing_patterns_found: 16 | |||
| status: 'complete' | |||
| rule_count: 78 | |||
| optimized_for_llm: true | |||
| --- | |||
| # Project Context for AI Agents | |||
| _This file contains critical rules and patterns that AI agents must follow when implementing code in this project. Focus on unobvious details that agents might otherwise miss._ | |||
| --- | |||
| ## Technology Stack & Versions | |||
| - Backend is ASP.NET Core on .NET 10 with nullable reference types and implicit usings enabled. | |||
| - Backend and frontend are separate applications in one repo: `Campaign_Tracker.Server/` and `campaign-tracker-client/`. | |||
| - Backend authentication uses JWT Bearer with Keycloak/OIDC integration; agents must reuse existing auth options, role normalization, and authorization policies. | |||
| - Server-side RBAC is mandatory. Frontend permission checks are UX only and must not replace backend `[Authorize]` policies. | |||
| - Legacy Access integration uses `System.Data.OleDb`; live OleDb access is Windows-only. | |||
| - Legacy Access tables are immutable. New behavior belongs in extension-layer models/repositories and read models. | |||
| - Development can use in-memory/test data access when legacy connection config is absent; non-development must require explicit legacy configuration and must not silently fall back. | |||
| - Frontend is React 19 + TypeScript 6 + Vite 8. | |||
| - UI implementation uses Ant Design 5 and `@ant-design/icons`. | |||
| - Frontend app code should call relative `/api/...` URLs; Vite proxies `/api` to `https://localhost:7244` in development. | |||
| - Do not hard-code localhost API origins in React app code. | |||
| - Do not start duplicate backend processes on port `7244`; stop the existing server or use the configured launch profile. | |||
| - Keep React component modules separate from shared helper/contract modules when exports would violate `react-refresh/only-export-components`. | |||
| - Frontend tests live under `src`, so test files must also satisfy TypeScript build constraints. | |||
| - Backend tests use xUnit plus ASP.NET Core integration testing via `WebApplicationFactory`. | |||
| - Frontend tests use Vitest; production validation uses `tsc -b && vite build`. | |||
| - Standard validation commands are `dotnet test campaign-tracker.sln`, `npm test`, `npm run lint`, and `npm run build`. | |||
| - Vite's current large-chunk build warning is known and non-blocking unless a story specifically requires bundle optimization. | |||
| ## Critical Implementation Rules | |||
| ### Language-Specific Rules | |||
| - C# code uses nullable reference types; do not suppress nullability warnings casually. Model optional data explicitly with nullable types. | |||
| - Prefer immutable record types for DTOs, read models, and value-style domain data, matching existing controller/read-model patterns. | |||
| - Async repository/controller APIs should accept and pass through `CancellationToken` where existing patterns do. | |||
| - Keep backend errors explicit: return typed validation/problem responses or appropriate HTTP status codes instead of throwing for expected user/data validation cases. | |||
| - TypeScript uses strict build settings with `noUnusedLocals`, `noUnusedParameters`, `verbatimModuleSyntax`, and bundler module resolution. | |||
| - Use `import type` for type-only imports when needed by TypeScript/ESLint. | |||
| - Frontend contract modules should own API DTO types, fetch helpers, validation error classes, and pure helpers. | |||
| - React component files should export components only when React Fast Refresh lint rules would otherwise fail. | |||
| - Test files under `src` are included in TypeScript build; avoid Node-only APIs unless the test has explicit Node type support. | |||
| ### Framework-Specific Rules | |||
| - Backend endpoints should be controller-based and placed under `Campaign_Tracker.Server/Controllers`. | |||
| - Protect role-specific backend endpoints with existing `ApplicationPolicy` constants; do not create one-off role string checks in controllers. | |||
| - Register backend services in `Program.cs` using existing DI patterns: singleton in-memory stores/read models for current extension-layer features unless persistence is introduced by a story. | |||
| - Legacy Access reads must go through `ILegacyDataAccess` or schema/link abstractions; do not query OleDb directly from controllers. | |||
| - React feature code should live under feature folders such as `municipalities`, `electionCycles`, `admin`, `workspace`, and `auth`. | |||
| - Workspace views should integrate through `WorkspaceShell` navigation and permission checks rather than creating separate top-level app shells. | |||
| - Use Ant Design components and tokens for workspace UI; avoid bespoke controls when Ant Design has an equivalent. | |||
| - Use `@ant-design/icons` for icon buttons/actions. | |||
| - Frontend fetch helpers should accept an optional `fetcher` parameter for tests and authenticated fetch injection. | |||
| - Authenticated frontend calls should flow through existing `authenticatedFetch` / session patterns rather than direct token handling in feature components. | |||
| - Keep Vite dev proxy assumptions localized to config and relative `/api` frontend URLs. | |||
| ### Testing Rules | |||
| - Add or update backend xUnit tests for every backend behavior change; use repository/unit tests for business logic and `WebApplicationFactory` tests for endpoint/RBAC behavior. | |||
| - Integration tests should use `AuthIntegrationTestFactory` and `AuthIntegrationTestFactory.CreateToken(...)` for authenticated requests. | |||
| - Backend authorization changes must include 401/403 coverage where practical. | |||
| - Legacy compatibility changes must include tests proving legacy Access tables remain read-only or that writes occur only through extension-layer abstractions. | |||
| - Frontend API contract helpers need Vitest coverage for success and non-OK responses. | |||
| - Frontend UI/view behavior should be tested with existing lightweight patterns; do not add new browser/E2E dependencies unless the story requires them. | |||
| - For React view tests rendered with server/static markup, also test pure helper functions for keyboard/windowing logic when DOM interaction libraries are not present. | |||
| - Run focused tests red/green during implementation, then run full validation before marking BMad story tasks complete. | |||
| - Required validation before review: `dotnet test campaign-tracker.sln`, `npm test`, `npm run lint`, and `npm run build`. | |||
| ### Code Quality & Style Rules | |||
| - Keep changes scoped to the active story; avoid opportunistic refactors outside the story acceptance criteria. | |||
| - Follow existing folder naming: backend feature folders are PascalCase; frontend feature folders are camelCase. | |||
| - Backend controllers and repositories should use clear domain names matching existing patterns (`Municipality...`, `ElectionCycle...`, `Legacy...`). | |||
| - Prefer small, stable DTO/read-model contracts because later stories depend on field names. | |||
| - Do not place generated build output, audit logs, temp files, `bin`, `obj`, or frontend `dist` content in story file lists. | |||
| - Use concise comments only for non-obvious domain or integration rules, especially legacy/extension boundary decisions. | |||
| - Frontend CSS should use existing workspace variables/tokens where available. | |||
| - Avoid new UI palettes or decorative styling that conflicts with the Ant Design workspace foundation. | |||
| - Keep React component text compact and operational; this is a dense internal line-of-business workspace, not a marketing page. | |||
| - Maintain accessible focus styles for keyboard-operable workspace controls. | |||
| - Do not silence ESLint or TypeScript errors unless the story explicitly requires an exception and the reason is documented. | |||
| ### Development Workflow Rules | |||
| - BMad story implementation must update only permitted story sections: task checkboxes, Dev Agent Record, File List, Change Log, and Status. | |||
| - Do not mark story tasks complete until implementation exists, related tests pass, and acceptance criteria are satisfied. | |||
| - Keep `_bmad-output/implementation-artifacts/sprint-status.yaml` in sync when moving a story from `ready-for-dev` to `in-progress` to `review`. | |||
| - When adding files, update the story File List with source/test/config files that changed, not generated artifacts. | |||
| - Before running or starting local servers, check whether required ports are already in use. | |||
| - Vite dev normally uses `5173`; backend HTTPS profile uses `7244` and HTTP uses `5254`. | |||
| - If port `7244` is in use, do not start a duplicate backend; stop the existing `Campaign_Tracker.Server` process or use a different launch profile intentionally. | |||
| - Use focused tests while developing, then run full backend/frontend validations before setting a story to `review`. | |||
| - Preserve user or prior-agent changes in the worktree; do not revert unrelated edits. | |||
| ### Critical Don't-Miss Rules | |||
| - Never mutate original legacy Access schema or tables. All new operational data belongs in extension-layer storage and is joined back to legacy data by stable keys. | |||
| - Treat `JCode` / `JurisCode`, `ID`, and `KitID` as critical deterministic join keys; do not normalize them in ways that break legacy joins. | |||
| - Do not bypass `LegacyLinkValidator`, legacy schema compatibility checks, or link integrity services when adding extension records tied to legacy data. | |||
| - Do not move Story 2.2+ behavior into Story 2.1-style route stubs; respect story boundaries and acceptance criteria. | |||
| - Do not trust frontend permissions as security. Backend policies must enforce role access. | |||
| - Do not create new auth, audit, or RBAC mechanisms when existing infrastructure already exists. | |||
| - Do not directly couple React views to raw backend implementation details; keep stable frontend contract helpers. | |||
| - Do not assume all municipalities have active cycle jobs, contacts, addresses, or prior-cycle defaults; empty states are required. | |||
| - Do not remove keyboard access or visible focus indicators from workspace controls. | |||
| - Do not ignore production environment differences: live OleDb requires Windows and explicit legacy configuration. | |||
| - Do not treat known Vite large-chunk warnings as a failure unless bundle optimization is in scope. | |||
| --- | |||
| ## Usage Guidelines | |||
| **For AI Agents:** | |||
| - Read this file before implementing any code. | |||
| - Follow all rules as documented. | |||
| - When in doubt, prefer the more restrictive option. | |||
| - Update this file if new project patterns emerge. | |||
| **For Humans:** | |||
| - Keep this file lean and focused on agent needs. | |||
| - Update it when the technology stack or implementation patterns change. | |||
| - Review periodically for outdated rules. | |||
| - Remove rules that become obvious or no longer prevent mistakes. | |||
| Last Updated: 2026-05-07 | |||
| @@ -0,0 +1,106 @@ | |||
| /// <reference types="node" /> | |||
| import { renderToStaticMarkup } from 'react-dom/server' | |||
| import { describe, expect, it, vi } from 'vitest' | |||
| import { CreateJobModal } from './CreateJobModal' | |||
| import { | |||
| createElectionCycleJob, | |||
| UnassignedCycleId, | |||
| type ElectionCycleKanbanBoard, | |||
| } from './electionCycleKanbanContracts' | |||
| vi.mock('./electionCycleKanbanContracts', async (importOriginal) => { | |||
| const actual = await importOriginal<typeof import('./electionCycleKanbanContracts')>() | |||
| return { | |||
| ...actual, | |||
| createElectionCycleJob: vi.fn(), | |||
| } | |||
| }) | |||
| const mockedCreateJob = createElectionCycleJob as vi.MockedFunction<typeof createElectionCycleJob> | |||
| const board: ElectionCycleKanbanBoard = { | |||
| lanes: [ | |||
| { | |||
| cycleId: '2026-primary', | |||
| cycleName: '2026 Primary', | |||
| cards: [], | |||
| }, | |||
| { | |||
| cycleId: UnassignedCycleId, | |||
| cycleName: 'Unassigned', | |||
| cards: [], | |||
| }, | |||
| ], | |||
| } | |||
| describe('CreateJobModal', () => { | |||
| it('renders the modal title with municipality name and JCode', () => { | |||
| const html = renderToStaticMarkup( | |||
| <CreateJobModal | |||
| board={board} | |||
| jCode="FAIR01" | |||
| municipalityName="Fairview Borough" | |||
| open={true} | |||
| onClose={() => undefined} | |||
| onSuccess={() => undefined} | |||
| fetcher={fetch} | |||
| />, | |||
| ) | |||
| expect(html).toContain('Create cycle job') | |||
| expect(html).toContain('Fairview Borough') | |||
| expect(html).toContain('FAIR01') | |||
| }) | |||
| it('shows existing cycle options from the board lanes', () => { | |||
| const html = renderToStaticMarkup( | |||
| <CreateJobModal | |||
| board={board} | |||
| jCode="PINE03" | |||
| municipalityName="Pine County" | |||
| open={true} | |||
| onClose={() => undefined} | |||
| onSuccess={() => undefined} | |||
| fetcher={fetch} | |||
| />, | |||
| ) | |||
| expect(html).toContain('2026 Primary') | |||
| expect(html).toContain('Existing cycle') | |||
| expect(html).toContain('New cycle') | |||
| }) | |||
| it('shows the create job button', () => { | |||
| const html = renderToStaticMarkup( | |||
| <CreateJobModal | |||
| board={board} | |||
| jCode="LAKE02" | |||
| municipalityName="Lake Township" | |||
| open={true} | |||
| onClose={() => undefined} | |||
| onSuccess={() => undefined} | |||
| fetcher={fetch} | |||
| />, | |||
| ) | |||
| expect(html).toContain('Create job') | |||
| }) | |||
| }) | |||
| describe('createElectionCycleJob', () => { | |||
| it('throws an error with the server message on non-ok response', async () => { | |||
| const mockFetch = vi.fn().mockResolvedValue({ | |||
| ok: false, | |||
| status: 422, | |||
| json: async () => ({ error: 'Cycle selection is required.' }), | |||
| }) | |||
| await expect( | |||
| createElectionCycleJob( | |||
| { jCode: 'FAIR01' }, | |||
| mockFetch as unknown as typeof fetch, | |||
| ), | |||
| ).rejects.toThrow('Cycle selection is required.') | |||
| }) | |||
| }) | |||
| @@ -0,0 +1,119 @@ | |||
| import { PlusOutlined } from '@ant-design/icons' | |||
| import { Button, Form, Input, Modal, Select, message } from 'antd' | |||
| import { useCallback, useMemo, useState } from 'react' | |||
| import { | |||
| createElectionCycleJob, | |||
| UnassignedCycleId, | |||
| type CreateElectionCycleJobRequest, | |||
| type ElectionCycleKanbanBoard, | |||
| } from './electionCycleKanbanContracts' | |||
| export function CreateJobModal({ | |||
| board, | |||
| jCode, | |||
| municipalityName, | |||
| open, | |||
| onClose, | |||
| onSuccess, | |||
| fetcher, | |||
| }: { | |||
| board: ElectionCycleKanbanBoard | |||
| jCode: string | |||
| municipalityName: string | |||
| open: boolean | |||
| onClose: () => void | |||
| onSuccess: () => void | |||
| fetcher: typeof fetch | |||
| }) { | |||
| const [form] = Form.useForm() | |||
| const [loading, setLoading] = useState(false) | |||
| const [mode, setMode] = useState<'existing' | 'new'>('existing') | |||
| const existingCycles = useMemo( | |||
| () => | |||
| board.lanes | |||
| .filter((lane) => lane.cycleId !== UnassignedCycleId) | |||
| .map((lane) => ({ value: lane.cycleId, label: lane.cycleName })), | |||
| [board], | |||
| ) | |||
| const handleClose = useCallback(() => { | |||
| form.resetFields() | |||
| setMode('existing') | |||
| onClose() | |||
| }, [form, onClose]) | |||
| const handleSubmit = useCallback( | |||
| async (values: { cycleId?: string; newCycleName?: string }) => { | |||
| setLoading(true) | |||
| try { | |||
| const request: CreateElectionCycleJobRequest = | |||
| mode === 'existing' && values.cycleId | |||
| ? { jCode, cycleId: values.cycleId } | |||
| : { jCode, cycleName: values.newCycleName } | |||
| await createElectionCycleJob(request, fetcher) | |||
| message.success(`Created job for ${municipalityName}`) | |||
| handleClose() | |||
| onSuccess() | |||
| } catch (error) { | |||
| const msg = error instanceof Error ? error.message : 'Failed to create job' | |||
| message.error(msg) | |||
| } finally { | |||
| setLoading(false) | |||
| } | |||
| }, | |||
| [jCode, mode, municipalityName, handleClose, onSuccess, fetcher], | |||
| ) | |||
| return ( | |||
| <Modal | |||
| title={`Create cycle job — ${municipalityName} (${jCode})`} | |||
| open={open} | |||
| onCancel={handleClose} | |||
| maskClosable | |||
| destroyOnClose | |||
| footer={null} | |||
| > | |||
| <Form form={form} layout="vertical" onFinish={handleSubmit}> | |||
| <Form.Item label="Assign to cycle"> | |||
| <Select | |||
| onChange={(value) => setMode(value === 'new' ? 'new' : 'existing')} | |||
| options={[ | |||
| { value: 'existing', label: 'Existing cycle' }, | |||
| { value: 'new', label: 'New cycle' }, | |||
| ]} | |||
| defaultValue="existing" | |||
| /> | |||
| </Form.Item> | |||
| {mode === 'existing' ? ( | |||
| <Form.Item | |||
| name="cycleId" | |||
| label="Cycle" | |||
| rules={[{ required: true, message: 'Select a cycle' }]} | |||
| > | |||
| <Select options={existingCycles} placeholder="Select an existing cycle" /> | |||
| </Form.Item> | |||
| ) : ( | |||
| <Form.Item | |||
| name="newCycleName" | |||
| label="New cycle name" | |||
| rules={[{ required: true, message: 'Enter a cycle name' }]} | |||
| > | |||
| <Input placeholder="e.g. 2026 Primary" /> | |||
| </Form.Item> | |||
| )} | |||
| <Form.Item style={{ marginTop: 24 }}> | |||
| <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> | |||
| <Button onClick={handleClose}>Cancel</Button> | |||
| <Button type="primary" htmlType="submit" loading={loading} icon={<PlusOutlined />}> | |||
| Create job | |||
| </Button> | |||
| </div> | |||
| </Form.Item> | |||
| </Form> | |||
| </Modal> | |||
| ) | |||
| } | |||
| @@ -0,0 +1,97 @@ | |||
| .election-cycle-kanban { | |||
| display: flex; | |||
| flex-direction: column; | |||
| min-height: 0; | |||
| gap: 12px; | |||
| } | |||
| .election-cycle-kanban__toolbar { | |||
| align-items: center; | |||
| display: flex; | |||
| justify-content: space-between; | |||
| gap: 16px; | |||
| } | |||
| .election-cycle-kanban__toolbar h2 { | |||
| margin: 0; | |||
| } | |||
| .election-cycle-kanban__lanes { | |||
| display: grid; | |||
| grid-auto-columns: minmax(280px, 1fr); | |||
| grid-auto-flow: column; | |||
| gap: 12px; | |||
| min-height: 480px; | |||
| overflow-x: auto; | |||
| padding-bottom: 4px; | |||
| } | |||
| .election-cycle-kanban__lane { | |||
| border: 1px solid var(--workspace-border); | |||
| border-radius: 6px; | |||
| display: flex; | |||
| flex-direction: column; | |||
| min-height: 0; | |||
| max-height: calc(100vh - 230px); | |||
| overflow-y: auto; | |||
| background: var(--workspace-surface); | |||
| } | |||
| .election-cycle-kanban__lane-header { | |||
| background: var(--workspace-surface); | |||
| border-bottom: 1px solid var(--workspace-border); | |||
| padding: 10px 12px; | |||
| position: sticky; | |||
| top: 0; | |||
| z-index: 1; | |||
| } | |||
| .election-cycle-kanban__lane-header > div { | |||
| align-items: center; | |||
| display: flex; | |||
| justify-content: space-between; | |||
| gap: 8px; | |||
| } | |||
| .election-cycle-kanban__card-list { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 8px; | |||
| padding: 10px; | |||
| } | |||
| .election-cycle-kanban__card { | |||
| border: 1px solid var(--workspace-border); | |||
| border-radius: 6px; | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 8px; | |||
| padding: 10px; | |||
| } | |||
| .election-cycle-kanban__card:focus, | |||
| .election-cycle-kanban__card:focus-visible, | |||
| .election-cycle-kanban__card button:focus-visible { | |||
| outline: 3px solid var(--workspace-focus); | |||
| outline-offset: 2px; | |||
| } | |||
| .election-cycle-kanban__card > div:first-child { | |||
| align-items: flex-start; | |||
| display: flex; | |||
| justify-content: space-between; | |||
| gap: 8px; | |||
| } | |||
| .election-cycle-kanban__empty, | |||
| .election-cycle-kanban__window-note { | |||
| padding: 8px 2px; | |||
| } | |||
| .election-cycle-kanban__keyboard-hint { | |||
| align-items: center; | |||
| color: var(--workspace-text-secondary); | |||
| display: flex; | |||
| gap: 6px; | |||
| justify-content: flex-end; | |||
| } | |||
| @@ -0,0 +1,107 @@ | |||
| /// <reference types="node" /> | |||
| import { readFileSync } from 'node:fs' | |||
| import { renderToStaticMarkup } from 'react-dom/server' | |||
| import { describe, expect, it } from 'vitest' | |||
| import { | |||
| getVisibleKanbanCards, | |||
| moveKanbanFocus, | |||
| type ElectionCycleKanbanBoard, | |||
| } from './electionCycleKanbanContracts' | |||
| import { ElectionCycleKanbanView } from './electionCycleKanbanView' | |||
| const board: ElectionCycleKanbanBoard = { | |||
| lanes: [ | |||
| { | |||
| cycleId: '2026-primary', | |||
| cycleName: '2026 Primary', | |||
| cards: [ | |||
| { | |||
| cardId: 'job-fair01-primary', | |||
| municipalityName: 'Fairview Borough', | |||
| jCode: 'FAIR01', | |||
| cycleId: '2026-primary', | |||
| cycleName: '2026 Primary', | |||
| cycleJobStatus: 'Ready', | |||
| legacyJoinKey: 'FAIR01', | |||
| quickOpenHref: '/election-cycles/jobs/job-fair01-primary', | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| cycleId: '__unassigned__', | |||
| cycleName: 'Unassigned', | |||
| cards: [ | |||
| { | |||
| cardId: 'unassigned-pine03', | |||
| municipalityName: 'Pine County', | |||
| jCode: 'PINE03', | |||
| cycleId: '__unassigned__', | |||
| cycleName: 'Unassigned', | |||
| cycleJobStatus: 'Unassigned', | |||
| legacyJoinKey: 'PINE03', | |||
| quickOpenHref: '/election-cycles/jobs/new?jCode=PINE03', | |||
| }, | |||
| ], | |||
| }, | |||
| ], | |||
| } | |||
| describe('ElectionCycleKanbanView', () => { | |||
| it('renders cycle lanes, the always-present Unassigned lane, and required card fields', () => { | |||
| const html = renderToStaticMarkup( | |||
| <ElectionCycleKanbanView board={board} onQuickOpen={() => undefined} />, | |||
| ) | |||
| expect(html).toContain('2026 Primary') | |||
| expect(html).toContain('Unassigned') | |||
| expect(html).toContain('Fairview Borough') | |||
| expect(html).toContain('FAIR01') | |||
| expect(html).toContain('Ready') | |||
| expect(html).toContain('Quick open') | |||
| }) | |||
| it('keeps lane headers sticky inside scrollable lane columns', () => { | |||
| const html = renderToStaticMarkup( | |||
| <ElectionCycleKanbanView board={board} onQuickOpen={() => undefined} />, | |||
| ) | |||
| const css = readFileSync( | |||
| new URL('./electionCycleKanban.css', import.meta.url), | |||
| 'utf8', | |||
| ) | |||
| expect(html).toContain('election-cycle-kanban__lane-header') | |||
| expect(css).toContain('.election-cycle-kanban__lane-header') | |||
| expect(css).toContain('position: sticky') | |||
| expect(css).toContain('overflow-y: auto') | |||
| }) | |||
| it('windows long lane lists while preserving first-card keyboard reachability', () => { | |||
| const cards = Array.from({ length: 75 }, (_, index) => ({ | |||
| ...board.lanes[0].cards[0], | |||
| cardId: `job-${index}`, | |||
| municipalityName: `Municipality ${index}`, | |||
| })) | |||
| const visible = getVisibleKanbanCards(cards, 10) | |||
| expect(visible).toHaveLength(10) | |||
| expect(visible[0].cardId).toBe('job-0') | |||
| expect(visible[9].cardId).toBe('job-9') | |||
| }) | |||
| it('moves keyboard focus across cards and lanes with arrow keys', () => { | |||
| expect(moveKanbanFocus(board, { laneIndex: 0, cardIndex: 0 }, 'ArrowRight')).toEqual({ | |||
| laneIndex: 1, | |||
| cardIndex: 0, | |||
| }) | |||
| expect(moveKanbanFocus(board, { laneIndex: 1, cardIndex: 0 }, 'ArrowLeft')).toEqual({ | |||
| laneIndex: 0, | |||
| cardIndex: 0, | |||
| }) | |||
| expect(moveKanbanFocus(board, { laneIndex: 0, cardIndex: 0 }, 'ArrowDown')).toEqual({ | |||
| laneIndex: 0, | |||
| cardIndex: 0, | |||
| }) | |||
| }) | |||
| }) | |||
| @@ -0,0 +1,141 @@ | |||
| const DefaultVisibleCardLimit = 50 | |||
| export const UnassignedCycleId = '__unassigned__' | |||
| export const UnassignedCycleName = 'Unassigned' | |||
| export type ElectionCycleKanbanCard = { | |||
| cardId: string | |||
| municipalityName: string | |||
| jCode: string | |||
| cycleId: string | |||
| cycleName: string | |||
| cycleJobStatus: string | |||
| legacyJoinKey: string | |||
| quickOpenHref: string | |||
| } | |||
| export type ElectionCycleKanbanLane = { | |||
| cycleId: string | |||
| cycleName: string | |||
| cards: ElectionCycleKanbanCard[] | |||
| } | |||
| export type ElectionCycleKanbanBoard = { | |||
| lanes: ElectionCycleKanbanLane[] | |||
| } | |||
| export type KanbanFocusPosition = { | |||
| laneIndex: number | |||
| cardIndex: number | |||
| } | |||
| export async function fetchElectionCycleKanban( | |||
| fetcher: typeof fetch, | |||
| ): Promise<ElectionCycleKanbanBoard> { | |||
| const response = await fetcher('/api/election-cycles/kanban') | |||
| if (!response.ok) { | |||
| throw new Error(`Failed to load election cycle kanban (${response.status})`) | |||
| } | |||
| return (await response.json()) as ElectionCycleKanbanBoard | |||
| } | |||
| export function getVisibleKanbanCards( | |||
| cards: ElectionCycleKanbanCard[], | |||
| limit?: number, | |||
| ): ElectionCycleKanbanCard[] { | |||
| if (limit === undefined || limit >= cards.length) { | |||
| return cards | |||
| } | |||
| return cards.slice(0, limit) | |||
| } | |||
| export const DefaultKanbanWindowSize = DefaultVisibleCardLimit | |||
| export type CreateElectionCycleJobRequest = { | |||
| jCode: string | |||
| cycleId?: string | |||
| cycleName?: string | |||
| } | |||
| export type ElectionCycleJobResponse = { | |||
| jobId: string | |||
| jCode: string | |||
| cycleId: string | |||
| cycleName: string | |||
| status: string | |||
| createdBy: string | |||
| createdAt: string | |||
| } | |||
| export async function createElectionCycleJob( | |||
| request: CreateElectionCycleJobRequest, | |||
| fetcher: typeof fetch, | |||
| ): Promise<ElectionCycleJobResponse> { | |||
| const response = await fetcher('/api/election-cycles/jobs', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(request), | |||
| }) | |||
| if (!response.ok) { | |||
| const problem = (await response.json().catch(() => null)) as { error?: string } | null | |||
| throw new Error(problem?.error ?? `Failed to create election cycle job (${response.status})`) | |||
| } | |||
| return (await response.json()) as ElectionCycleJobResponse | |||
| } | |||
| export function moveKanbanFocus( | |||
| board: ElectionCycleKanbanBoard, | |||
| current: KanbanFocusPosition, | |||
| key: string, | |||
| ): KanbanFocusPosition { | |||
| const laneCount = board.lanes.length | |||
| if (laneCount === 0) { | |||
| return current | |||
| } | |||
| const safeLaneIndex = Math.min(Math.max(current.laneIndex, 0), laneCount - 1) | |||
| const currentLane = board.lanes[safeLaneIndex] | |||
| const safeCardIndex = Math.min( | |||
| Math.max(current.cardIndex, 0), | |||
| Math.max(currentLane.cards.length - 1, 0), | |||
| ) | |||
| const nextLaneIndex = | |||
| key === 'ArrowRight' | |||
| ? Math.min(safeLaneIndex + 1, laneCount - 1) | |||
| : key === 'ArrowLeft' | |||
| ? Math.max(safeLaneIndex - 1, 0) | |||
| : safeLaneIndex | |||
| const nextLane = board.lanes[nextLaneIndex] | |||
| const maxCardIndex = Math.max(nextLane.cards.length - 1, 0) | |||
| if (key === 'ArrowDown') { | |||
| return { | |||
| laneIndex: safeLaneIndex, | |||
| cardIndex: Math.min(safeCardIndex + 1, Math.max(currentLane.cards.length - 1, 0)), | |||
| } | |||
| } | |||
| if (key === 'ArrowUp') { | |||
| return { | |||
| laneIndex: safeLaneIndex, | |||
| cardIndex: Math.max(safeCardIndex - 1, 0), | |||
| } | |||
| } | |||
| return { | |||
| laneIndex: nextLaneIndex, | |||
| cardIndex: Math.min(safeCardIndex, maxCardIndex), | |||
| } | |||
| } | |||
| export function findFirstFocusablePosition( | |||
| board: ElectionCycleKanbanBoard, | |||
| ): KanbanFocusPosition { | |||
| for (let i = 0; i < board.lanes.length; i++) { | |||
| if (board.lanes[i].cards.length > 0) { | |||
| return { laneIndex: i, cardIndex: 0 } | |||
| } | |||
| } | |||
| return { laneIndex: 0, cardIndex: 0 } | |||
| } | |||
| @@ -0,0 +1,267 @@ | |||
| import { ArrowDownOutlined, ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons' | |||
| import { Alert, Button, Space, Tag, Typography } from 'antd' | |||
| import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' | |||
| import { CreateJobModal } from './CreateJobModal' | |||
| import { | |||
| findFirstFocusablePosition, | |||
| getVisibleKanbanCards, | |||
| moveKanbanFocus, | |||
| UnassignedCycleId, | |||
| UnassignedCycleName, | |||
| type ElectionCycleKanbanBoard, | |||
| type KanbanFocusPosition, | |||
| } from './electionCycleKanbanContracts' | |||
| import './electionCycleKanban.css' | |||
| const { Text, Title } = Typography | |||
| export function ElectionCycleKanbanPanel({ | |||
| load, | |||
| onQuickOpen, | |||
| fetcher, | |||
| }: { | |||
| load: () => Promise<ElectionCycleKanbanBoard> | |||
| onQuickOpen: (href: string) => void | |||
| fetcher: typeof fetch | |||
| }) { | |||
| const [board, setBoard] = useState<ElectionCycleKanbanBoard>({ lanes: [] }) | |||
| const [error, setError] = useState<string | null>(null) | |||
| const [loading, setLoading] = useState(true) | |||
| const [createJobTarget, setCreateJobTarget] = useState<{ | |||
| jCode: string | |||
| municipalityName: string | |||
| } | null>(null) | |||
| const reload = useMemo( | |||
| () => async () => { | |||
| const nextBoard = await load() | |||
| setBoard(ensureUnassignedLane(nextBoard)) | |||
| setError(null) | |||
| }, | |||
| [load], | |||
| ) | |||
| useEffect(() => { | |||
| let active = true | |||
| load() | |||
| .then((nextBoard) => { | |||
| if (active) { | |||
| setBoard(ensureUnassignedLane(nextBoard)) | |||
| setError(null) | |||
| } | |||
| }) | |||
| .catch((err: unknown) => { | |||
| if (active) { | |||
| setError(err instanceof Error ? err.message : 'Failed to load election cycle kanban.') | |||
| } | |||
| }) | |||
| .finally(() => { | |||
| if (active) { | |||
| setLoading(false) | |||
| } | |||
| }) | |||
| return () => { | |||
| active = false | |||
| } | |||
| }, [load]) | |||
| const handleCreateJobSuccess = () => { | |||
| setCreateJobTarget(null) | |||
| reload() | |||
| } | |||
| if (error) { | |||
| return ( | |||
| <Alert | |||
| type="error" | |||
| showIcon | |||
| message="Election cycles unavailable" | |||
| description={error} | |||
| /> | |||
| ) | |||
| } | |||
| return ( | |||
| <> | |||
| <ElectionCycleKanbanView | |||
| board={board} | |||
| loading={loading} | |||
| onQuickOpen={onQuickOpen} | |||
| onCreateJob={(jCode, municipalityName) => setCreateJobTarget({ jCode, municipalityName })} | |||
| /> | |||
| {createJobTarget !== null && ( | |||
| <CreateJobModal | |||
| board={board} | |||
| jCode={createJobTarget.jCode} | |||
| municipalityName={createJobTarget.municipalityName} | |||
| open={true} | |||
| onClose={() => setCreateJobTarget(null)} | |||
| onSuccess={handleCreateJobSuccess} | |||
| fetcher={fetcher} | |||
| /> | |||
| )} | |||
| </> | |||
| ) | |||
| } | |||
| export function ElectionCycleKanbanView({ | |||
| board, | |||
| loading = false, | |||
| onQuickOpen, | |||
| onCreateJob, | |||
| }: { | |||
| board: ElectionCycleKanbanBoard | |||
| loading?: boolean | |||
| onQuickOpen: (href: string) => void | |||
| onCreateJob?: (jCode: string, municipalityName: string) => void | |||
| }) { | |||
| const normalizedBoard = useMemo(() => ensureUnassignedLane(board), [board]) | |||
| const [focus, setFocus] = useState<KanbanFocusPosition>(() => | |||
| findFirstFocusablePosition(normalizedBoard), | |||
| ) | |||
| const cardRefs = useRef(new Map<string, HTMLElement>()) | |||
| useEffect(() => { | |||
| setFocus((current) => { | |||
| const lane = normalizedBoard.lanes[current.laneIndex] | |||
| if (lane && lane.cards.length > 0) { | |||
| return current | |||
| } | |||
| return findFirstFocusablePosition(normalizedBoard) | |||
| }) | |||
| }, [normalizedBoard]) | |||
| useEffect(() => { | |||
| const activeLane = normalizedBoard.lanes[focus.laneIndex] | |||
| const activeCard = activeLane?.cards[focus.cardIndex] | |||
| if (!activeCard) { | |||
| return | |||
| } | |||
| cardRefs.current.get(activeCard.cardId)?.focus() | |||
| }, [focus, normalizedBoard]) | |||
| function handleCardKeyDown(event: KeyboardEvent<HTMLElement>) { | |||
| if (!event.key.startsWith('Arrow')) { | |||
| return | |||
| } | |||
| event.preventDefault() | |||
| setFocus((current) => moveKanbanFocus(normalizedBoard, current, event.key)) | |||
| } | |||
| return ( | |||
| <section className="election-cycle-kanban" aria-label="Election cycle kanban"> | |||
| <div className="election-cycle-kanban__toolbar"> | |||
| <div> | |||
| <Text className="workspace-kicker">Election cycle setup</Text> | |||
| <Title level={2}>Municipality cycle board</Title> | |||
| </div> | |||
| <Space size={8} wrap> | |||
| <Tag color="processing">Active jobs</Tag> | |||
| <Tag>Unassigned</Tag> | |||
| </Space> | |||
| </div> | |||
| <div className="election-cycle-kanban__lanes" aria-busy={loading}> | |||
| {normalizedBoard.lanes.map((lane, laneIndex) => { | |||
| const visibleCards = getVisibleKanbanCards(lane.cards) | |||
| const hiddenCount = lane.cards.length - visibleCards.length | |||
| return ( | |||
| <section | |||
| className="election-cycle-kanban__lane" | |||
| aria-label={`${lane.cycleName} lane`} | |||
| key={lane.cycleId} | |||
| > | |||
| <div className="election-cycle-kanban__lane-header"> | |||
| <div> | |||
| <Text strong>{lane.cycleName}</Text> | |||
| <Text type="secondary">{lane.cards.length} municipalities</Text> | |||
| </div> | |||
| </div> | |||
| <div className="election-cycle-kanban__card-list"> | |||
| {visibleCards.map((card, cardIndex) => ( | |||
| <article | |||
| className="election-cycle-kanban__card" | |||
| key={card.cardId} | |||
| ref={(element) => { | |||
| if (element) { | |||
| cardRefs.current.set(card.cardId, element) | |||
| } else { | |||
| cardRefs.current.delete(card.cardId) | |||
| } | |||
| }} | |||
| tabIndex={focus.laneIndex === laneIndex && focus.cardIndex === cardIndex ? 0 : -1} | |||
| onKeyDown={handleCardKeyDown} | |||
| > | |||
| <div> | |||
| <Text strong>{card.municipalityName}</Text> | |||
| <Text code>{card.jCode}</Text> | |||
| </div> | |||
| <Tag>{card.cycleJobStatus}</Tag> | |||
| <Space size={6} wrap> | |||
| <Button | |||
| size="small" | |||
| type="primary" | |||
| onClick={() => onQuickOpen(card.quickOpenHref)} | |||
| > | |||
| Quick open | |||
| </Button> | |||
| {card.cycleId === UnassignedCycleId && onCreateJob ? ( | |||
| <Button | |||
| size="small" | |||
| onClick={() => onCreateJob(card.jCode, card.municipalityName)} | |||
| > | |||
| Create cycle job | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| size="small" | |||
| onClick={() => | |||
| onQuickOpen(`/election-cycles/jobs/new?jCode=${encodeURIComponent(card.jCode)}`)} | |||
| > | |||
| Assign cycle | |||
| </Button> | |||
| )} | |||
| </Space> | |||
| </article> | |||
| ))} | |||
| {hiddenCount > 0 ? ( | |||
| <Text className="election-cycle-kanban__window-note" type="secondary"> | |||
| Showing {visibleCards.length} of {lane.cards.length} | |||
| </Text> | |||
| ) : null} | |||
| {lane.cards.length === 0 ? ( | |||
| <Text className="election-cycle-kanban__empty" type="secondary"> | |||
| No municipalities | |||
| </Text> | |||
| ) : null} | |||
| </div> | |||
| </section> | |||
| ) | |||
| })} | |||
| </div> | |||
| <div className="election-cycle-kanban__keyboard-hint" aria-hidden="true"> | |||
| <ArrowLeftOutlined /> <ArrowRightOutlined /> <ArrowDownOutlined /> | |||
| </div> | |||
| </section> | |||
| ) | |||
| } | |||
| function ensureUnassignedLane(board: ElectionCycleKanbanBoard): ElectionCycleKanbanBoard { | |||
| if (board.lanes.some((lane) => lane.cycleId === UnassignedCycleId)) { | |||
| return board | |||
| } | |||
| return { | |||
| lanes: [ | |||
| ...board.lanes, | |||
| { | |||
| cycleId: UnassignedCycleId, | |||
| cycleName: UnassignedCycleName, | |||
| cards: [], | |||
| }, | |||
| ], | |||
| } | |||
| } | |||
| @@ -23,7 +23,7 @@ import { | |||
| theme, | |||
| type TableProps, | |||
| } from 'antd' | |||
| import { useEffect, useState, type CSSProperties } from 'react' | |||
| import { useCallback, useEffect, useState, type CSSProperties } from 'react' | |||
| import { | |||
| isEditingAvailable, | |||
| isRightPanelCollapsible, | |||
| @@ -49,6 +49,8 @@ import { | |||
| fetchLegacySchemaCheckHistory, | |||
| runLegacySchemaCheck, | |||
| } from '../admin/legacySchemaContracts' | |||
| import { fetchElectionCycleKanban } from '../electionCycles/electionCycleKanbanContracts' | |||
| import { ElectionCycleKanbanPanel } from '../electionCycles/electionCycleKanbanView' | |||
| import './WorkspaceShell.css' | |||
| const { Header, Sider, Content } = Layout | |||
| @@ -286,6 +288,13 @@ export function WorkspaceShell({ | |||
| const rightPanelCollapsed = | |||
| canCollapseRightPanel && rightPanelCollapseRequested | |||
| const { token } = theme.useToken() | |||
| const loadElectionCycleKanban = useCallback( | |||
| () => fetchElectionCycleKanban(adminFetch), | |||
| [adminFetch], | |||
| ) | |||
| const quickOpenElectionCycle = useCallback((href: string) => { | |||
| window.history.pushState(null, '', href) | |||
| }, []) | |||
| const initialView = user.permissions.canViewMunicipalityProfile | |||
| ? 'municipalities' | |||
| : user.permissions.canCreateElectionCycle | |||
| @@ -423,6 +432,12 @@ export function WorkspaceShell({ | |||
| loadPriorCycleDefaults={(profileId) => | |||
| fetchPriorCycleDefaults(profileId, adminFetch)} | |||
| /> | |||
| ) : selectedView === 'cycles' && user.permissions.canCreateElectionCycle ? ( | |||
| <ElectionCycleKanbanPanel | |||
| load={loadElectionCycleKanban} | |||
| onQuickOpen={quickOpenElectionCycle} | |||
| fetcher={adminFetch} | |||
| /> | |||
| ) : ( | |||
| <section | |||
| className="workspace-board" | |||
Powered by TurnKey Linux.