added free agents

This commit is contained in:
2026-02-22 22:44:17 +11:00
parent d011c7acb8
commit f3d1395076
15 changed files with 1549 additions and 98 deletions

View File

@@ -17,6 +17,8 @@ templ FixtureDetailPage(
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
activeTab string,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) {
{{
permCache := contexts.Permissions(ctx)
@@ -78,10 +80,10 @@ templ FixtureDetailPage(
</nav>
}
</div>
<!-- Tab Content -->
if activeTab == "overview" {
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage)
} else if activeTab == "schedule" {
<!-- Tab Content -->
if activeTab == "overview" {
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
} else if activeTab == "schedule" {
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
}
</div>
@@ -116,6 +118,10 @@ templ fixtureOverviewTab(
result *db.FixtureResult,
rosters map[string][]*db.PlayerWithPlayStatus,
canManage bool,
canSchedule bool,
userTeamID int,
nominatedFreeAgents []*db.FixtureFreeAgent,
availableFreeAgents []*db.SeasonLeagueFreeAgent,
) {
<div class="space-y-6">
<!-- Result + Schedule Row -->
@@ -135,6 +141,10 @@ templ fixtureOverviewTab(
@fixtureScheduleSummary(fixture, currentSchedule, result)
</div>
</div>
<!-- Free Agent Nominations (hidden when result is finalized) -->
if (result == nil || !result.Finalized) && (canSchedule || canManage || len(nominatedFreeAgents) > 0) {
@fixtureFreeAgentSection(fixture, canSchedule, canManage, userTeamID, nominatedFreeAgents, availableFreeAgents)
}
<!-- Team Rosters -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result)
@@ -400,6 +410,11 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
&#9733;
</span>
}
if p.IsFreeAgent {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FREE AGENT
</span>
}
</span>
</td>
if p.Stats != nil {
@@ -456,6 +471,11 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
&#9733; Manager
</span>
}
if p.IsFreeAgent {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FREE AGENT
</span>
}
</div>
}
</div>
@@ -482,6 +502,256 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
</div>
}
// ==================== Free Agent Section ====================
templ fixtureFreeAgentSection(
fixture *db.Fixture,
canSchedule bool,
canManage bool,
userTeamID int,
nominated []*db.FixtureFreeAgent,
available []*db.SeasonLeagueFreeAgent,
) {
{{
// Split nominated by team
homeNominated := []*db.FixtureFreeAgent{}
awayNominated := []*db.FixtureFreeAgent{}
for _, n := range nominated {
if n.TeamID == fixture.HomeTeamID {
homeNominated = append(homeNominated, n)
} else {
awayNominated = append(awayNominated, n)
}
}
// Filter available: exclude already nominated players
nominatedIDs := map[int]bool{}
for _, n := range nominated {
nominatedIDs[n.PlayerID] = true
}
filteredAvailable := []*db.SeasonLeagueFreeAgent{}
for _, fa := range available {
if !nominatedIDs[fa.PlayerID] {
filteredAvailable = append(filteredAvailable, fa)
}
}
// Can the user nominate?
canNominate := (canSchedule || canManage) && len(filteredAvailable) > 0
}}
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden" x-data="{ showNominateModal: false, selectedPlayerId: '', selectedTeamId: '' }">
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
<h2 class="text-lg font-bold text-text">Free Agent Nominations</h2>
if canNominate {
<button
@click="showNominateModal = true"
class="rounded-lg px-3 py-1.5 hover:cursor-pointer text-center text-xs
bg-peach hover:bg-peach/75 text-mantle transition"
>
Nominate Free Agent
</button>
}
</div>
<div class="p-4">
if len(nominated) == 0 {
<p class="text-subtext1 text-sm text-center py-2">No free agents nominated for this fixture.</p>
} else {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Home team nominations -->
<div>
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">{ fixture.HomeTeam.Name }</p>
if len(homeNominated) == 0 {
<p class="text-subtext1 text-xs italic">None</p>
} else {
<div class="space-y-1">
for _, n := range homeNominated {
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
<span class="flex items-center gap-2">
<span class="text-sm text-text">{ n.Player.DisplayName() }</span>
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FA
</span>
</span>
if canManage || (canSchedule && userTeamID == fixture.HomeTeamID) {
<form
hx-post={ fmt.Sprintf("/fixtures/%d/free-agents/%d/remove", fixture.ID, n.PlayerID) }
hx-swap="none"
class="inline"
>
<button
type="submit"
class="px-2 py-0.5 text-xs bg-red/20 hover:bg-red/40 text-red rounded
transition hover:cursor-pointer"
>
Remove
</button>
</form>
}
</div>
}
</div>
}
</div>
<!-- Away team nominations -->
<div>
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">{ fixture.AwayTeam.Name }</p>
if len(awayNominated) == 0 {
<p class="text-subtext1 text-xs italic">None</p>
} else {
<div class="space-y-1">
for _, n := range awayNominated {
<div class="flex items-center justify-between px-2 py-1.5 rounded hover:bg-surface0 transition">
<span class="flex items-center gap-2">
<span class="text-sm text-text">{ n.Player.DisplayName() }</span>
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FA
</span>
</span>
if canManage || (canSchedule && userTeamID == fixture.AwayTeamID) {
<form
hx-post={ fmt.Sprintf("/fixtures/%d/free-agents/%d/remove", fixture.ID, n.PlayerID) }
hx-swap="none"
class="inline"
>
<button
type="submit"
class="px-2 py-0.5 text-xs bg-red/20 hover:bg-red/40 text-red rounded
transition hover:cursor-pointer"
>
Remove
</button>
</form>
}
</div>
}
</div>
}
</div>
</div>
}
</div>
<!-- Nominate Modal -->
if canNominate {
<div
x-show="showNominateModal"
@keydown.escape.window="showNominateModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;"
>
<div
class="fixed inset-0 bg-crust/80 transition-opacity"
@click="showNominateModal = false"
></div>
<div class="flex min-h-full items-center justify-center p-4">
<div
class="relative bg-mantle border-2 border-surface1 rounded-xl shadow-xl max-w-md w-full p-6"
@click.stop
>
<h3 class="text-2xl font-bold text-text mb-4">Nominate Free Agent</h3>
<form
hx-post={ fmt.Sprintf("/fixtures/%d/free-agents/nominate", fixture.ID) }
hx-swap="none"
>
if canManage && !canSchedule {
<!-- Manager (not on either team): show team selector -->
<div class="mb-4">
<label for="fa_team_id" class="block text-sm font-medium mb-2">Nominating Team</label>
<select
id="fa_team_id"
name="team_id"
x-model="selectedTeamId"
required
class="w-full py-3 px-4 rounded-lg text-sm bg-base border-2 border-overlay0
focus:border-blue outline-none"
>
<option value="">Choose a team...</option>
<option value={ fmt.Sprint(fixture.HomeTeamID) }>
{ fixture.HomeTeam.Name }
</option>
<option value={ fmt.Sprint(fixture.AwayTeamID) }>
{ fixture.AwayTeam.Name }
</option>
</select>
</div>
} else if canManage && canSchedule {
<!-- Manager who is also on a team: show team selector pre-filled -->
<div class="mb-4">
<label for="fa_team_id" class="block text-sm font-medium mb-2">Nominating Team</label>
<select
id="fa_team_id"
name="team_id"
x-model="selectedTeamId"
x-init={ fmt.Sprintf("selectedTeamId = '%d'", userTeamID) }
required
class="w-full py-3 px-4 rounded-lg text-sm bg-base border-2 border-overlay0
focus:border-blue outline-none"
>
<option value="">Choose a team...</option>
<option value={ fmt.Sprint(fixture.HomeTeamID) }>
{ fixture.HomeTeam.Name }
</option>
<option value={ fmt.Sprint(fixture.AwayTeamID) }>
{ fixture.AwayTeam.Name }
</option>
</select>
</div>
} else {
<!-- Regular team manager: fixed to their team -->
<input type="hidden" name="team_id" value={ fmt.Sprint(userTeamID) }/>
}
<div class="mb-4">
<label for="fa_player_id" class="block text-sm font-medium mb-2">Select Free Agent</label>
<select
id="fa_player_id"
name="player_id"
x-model="selectedPlayerId"
required
class="w-full py-3 px-4 rounded-lg text-sm bg-base border-2 border-overlay0
focus:border-blue outline-none"
>
<option value="">Choose a free agent...</option>
for _, fa := range filteredAvailable {
<option value={ fmt.Sprint(fa.PlayerID) }>
{ fa.Player.DisplayName() }
</option>
}
</select>
</div>
<div class="flex gap-3 justify-end">
<button
type="button"
@click="showNominateModal = false"
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition hover:cursor-pointer"
>
Cancel
</button>
if canManage {
<button
type="submit"
:disabled="!selectedPlayerId || !selectedTeamId"
class="px-4 py-2 rounded-lg bg-peach hover:bg-peach/75 text-mantle transition
disabled:bg-peach/40 disabled:cursor-not-allowed hover:cursor-pointer"
>
Nominate
</button>
} else {
<button
type="submit"
:disabled="!selectedPlayerId"
class="px-4 py-2 rounded-lg bg-peach hover:bg-peach/75 text-mantle transition
disabled:bg-peach/40 disabled:cursor-not-allowed hover:cursor-pointer"
>
Nominate
</button>
}
</div>
</form>
</div>
</div>
</div>
}
</div>
}
// ==================== Schedule Tab ====================
templ fixtureScheduleTab(
fixture *db.Fixture,

View File

@@ -8,6 +8,7 @@ templ FixtureReviewResultPage(
fixture *db.Fixture,
result *db.FixtureResult,
unmappedPlayers []string,
unnominatedFreeAgents []FreeAgentWarning,
) {
{{
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
@@ -37,38 +38,56 @@ templ FixtureReviewResultPage(
</div>
</div>
</div>
<!-- Warnings Section -->
if result.TamperingDetected || len(unmappedPlayers) > 0 {
<div class="space-y-4 mb-6">
if result.TamperingDetected && result.TamperingReason != nil {
<div class="bg-red/10 border border-red/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<!-- Warnings Section -->
if result.TamperingDetected || len(unmappedPlayers) > 0 || len(unnominatedFreeAgents) > 0 {
<div class="space-y-4 mb-6">
if result.TamperingDetected && result.TamperingReason != nil {
<div class="bg-red/10 border border-red/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span>
</div>
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
<p class="text-red/60 text-xs mt-2">
This does not block finalization but should be reviewed carefully.
</p>
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
<p class="text-red/60 text-xs mt-2">
This does not block finalization but should be reviewed carefully.
</p>
</div>
}
if len(unnominatedFreeAgents) > 0 {
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-yellow font-bold text-sm">⚠ Free Agent Nomination Issues</span>
</div>
}
if len(unmappedPlayers) > 0 {
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
</div>
<p class="text-yellow/80 text-sm mb-2">
The following players could not be matched to registered players.
They may be free agents or have unregistered Slapshot IDs.
</p>
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
for _, p := range unmappedPlayers {
<li>{ p }</li>
}
</ul>
<p class="text-yellow/80 text-sm mb-2">
The following free agents have nomination issues that should be reviewed before finalizing.
</p>
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
for _, fa := range unnominatedFreeAgents {
<li>
<span class="text-yellow font-medium">{ fa.Name }</span>
<span class="text-yellow/60"> — { fa.Reason }</span>
</li>
}
</ul>
</div>
}
if len(unmappedPlayers) > 0 {
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
</div>
}
</div>
}
<p class="text-yellow/80 text-sm mb-2">
The following players could not be matched to registered players.
They may be free agents or have unregistered Slapshot IDs.
</p>
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
for _, p := range unmappedPlayers {
<li>{ p }</li>
}
</ul>
</div>
}
</div>
}
<!-- Score Overview -->
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
@@ -199,10 +218,17 @@ templ reviewTeamStats(team *db.Team, result *db.FixtureResult, side string) {
for _, ps := range finalStats {
<tr class="hover:bg-surface0 transition-colors">
<td class="px-3 py-2 text-sm text-text">
{ ps.Username }
if ps.PlayerID == nil {
<span class="text-yellow text-xs ml-1" title="Unmapped player">?</span>
}
<span class="flex items-center gap-1.5">
{ ps.Username }
if ps.PlayerID == nil {
<span class="text-yellow text-xs" title="Unmapped player">?</span>
}
if ps.Stats.IsFreeAgent {
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FREE AGENT
</span>
}
</span>
</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Goals) }</td>
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>

View File

@@ -0,0 +1,168 @@
package seasonsview
import "git.haelnorr.com/h/oslstats/internal/db"
import "git.haelnorr.com/h/oslstats/internal/permissions"
import "git.haelnorr.com/h/oslstats/internal/contexts"
import "fmt"
templ SeasonLeagueFreeAgentsPage(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) {
@SeasonLeagueLayout("free-agents", season, league) {
@SeasonLeagueFreeAgents(season, league, freeAgents, availablePlayers)
}
}
templ SeasonLeagueFreeAgents(season *db.Season, league *db.League, freeAgents []*db.SeasonLeagueFreeAgent, availablePlayers []*db.Player) {
{{
permCache := contexts.Permissions(ctx)
canAdd := permCache.HasPermission(permissions.FreeAgentsAdd)
canRemove := permCache.HasPermission(permissions.FreeAgentsRemove)
}}
<div x-data="{ showAddModal: false, selectedPlayerId: '' }">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-text">Free Agents ({ fmt.Sprint(len(freeAgents)) })</h2>
if canAdd {
<button
@click="showAddModal = true"
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center text-sm
bg-green hover:bg-green/75 text-mantle transition"
>
Add Free Agent
</button>
}
</div>
if len(freeAgents) == 0 {
<div class="bg-surface0 border border-surface1 rounded-lg p-8 text-center">
<p class="text-subtext0 text-lg">No free agents registered in this league yet.</p>
if canAdd {
<p class="text-subtext1 text-sm mt-2">Click "Add Free Agent" to register a player.</p>
}
</div>
} else {
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-mantle border-b border-surface1">
<tr>
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Player</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-text">Registered By</th>
if canRemove {
<th class="px-4 py-3 text-right text-sm font-semibold text-text">Actions</th>
}
</tr>
</thead>
<tbody class="divide-y divide-surface1">
for _, fa := range freeAgents {
<tr class="hover:bg-surface1 transition-colors">
<td class="px-4 py-3 text-sm text-text">
<span class="flex items-center gap-2">
{ fa.Player.DisplayName() }
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-medium">
FREE AGENT
</span>
</span>
</td>
<td class="px-4 py-3 text-sm text-subtext0">
if fa.RegisteredBy != nil {
{ fa.RegisteredBy.Username }
}
</td>
if canRemove {
<td class="px-4 py-3 text-right">
<form
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/free-agents/unregister", season.ShortName, league.ShortName) }
hx-swap="none"
class="inline"
>
<input type="hidden" name="player_id" value={ fmt.Sprint(fa.PlayerID) }/>
<button
type="submit"
class="px-3 py-1 text-xs bg-red/20 hover:bg-red/40 text-red rounded
transition hover:cursor-pointer"
>
Remove
</button>
</form>
</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
}
if canAdd {
@addFreeAgentModal(season, league, availablePlayers)
}
</div>
}
templ addFreeAgentModal(season *db.Season, league *db.League, availablePlayers []*db.Player) {
<div
x-show="showAddModal"
@keydown.escape.window="showAddModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;"
>
<!-- Backdrop -->
<div
class="fixed inset-0 bg-crust/80 transition-opacity"
@click="showAddModal = false"
></div>
<!-- Modal -->
<div class="flex min-h-full items-center justify-center p-4">
<div
class="relative bg-mantle border-2 border-surface1 rounded-xl shadow-xl max-w-md w-full p-6"
@click.stop
>
<h3 class="text-2xl font-bold text-text mb-4">Add Free Agent</h3>
<form
hx-post={ fmt.Sprintf("/seasons/%s/leagues/%s/free-agents/register", season.ShortName, league.ShortName) }
hx-swap="none"
>
if len(availablePlayers) == 0 {
<p class="text-subtext0 mb-4">No players available to register as free agents. All players are either on a team or already registered.</p>
} else {
<div class="mb-4">
<label for="player_id" class="block text-sm font-medium mb-2">Select Player</label>
<select
id="player_id"
name="player_id"
x-model="selectedPlayerId"
required
class="w-full py-3 px-4 rounded-lg text-sm bg-base border-2 border-overlay0
focus:border-blue outline-none"
>
<option value="">Choose a player...</option>
for _, player := range availablePlayers {
<option value={ fmt.Sprint(player.ID) }>
{ player.DisplayName() }
</option>
}
</select>
</div>
}
<div class="flex gap-3 justify-end">
<button
type="button"
@click="showAddModal = false"
class="px-4 py-2 rounded-lg bg-surface0 hover:bg-surface1 text-text transition hover:cursor-pointer"
>
Cancel
</button>
if len(availablePlayers) > 0 {
<button
type="submit"
:disabled="!selectedPlayerId"
class="px-4 py-2 rounded-lg bg-green hover:bg-green/75 text-mantle transition
disabled:bg-green/40 disabled:cursor-not-allowed hover:cursor-pointer"
>
Register Free Agent
</button>
}
</div>
</form>
</div>
</div>
</div>
}

View File

@@ -120,6 +120,7 @@ templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.Lea
@leagueNavItem("table", "Table", activeSection, season, league)
@leagueNavItem("fixtures", "Fixtures", activeSection, season, league)
@leagueNavItem("teams", "Teams", activeSection, season, league)
@leagueNavItem("free-agents", "Free Agents", activeSection, season, league)
@leagueNavItem("stats", "Stats", activeSection, season, league)
@leagueNavItem("finals", "Finals", activeSection, season, league)
</ul>

View File

@@ -0,0 +1,7 @@
package seasonsview
// FreeAgentWarning holds information about a free agent nomination issue for display.
type FreeAgentWarning struct {
Name string
Reason string
}