added free agents
This commit is contained in:
@@ -79,6 +79,7 @@ type FixtureResultPlayerStats struct {
|
|||||||
HasMercyRuled *int
|
HasMercyRuled *int
|
||||||
WasMercyRuled *int
|
WasMercyRuled *int
|
||||||
PeriodsPlayed *int
|
PeriodsPlayed *int
|
||||||
|
IsFreeAgent bool `bun:"is_free_agent,default:false"`
|
||||||
|
|
||||||
FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"`
|
FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"`
|
||||||
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
|
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
|
||||||
@@ -87,10 +88,11 @@ type FixtureResultPlayerStats struct {
|
|||||||
|
|
||||||
// PlayerWithPlayStatus is a helper struct for overview display
|
// PlayerWithPlayStatus is a helper struct for overview display
|
||||||
type PlayerWithPlayStatus struct {
|
type PlayerWithPlayStatus struct {
|
||||||
Player *Player
|
Player *Player
|
||||||
Played bool
|
Played bool
|
||||||
IsManager bool
|
IsManager bool
|
||||||
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result
|
IsFreeAgent bool
|
||||||
|
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
|
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
|
||||||
@@ -489,6 +491,12 @@ func GetFixtureTeamRosters(
|
|||||||
// Build maps of player IDs that played and their period 3 stats
|
// Build maps of player IDs that played and their period 3 stats
|
||||||
playedPlayerIDs := map[int]bool{}
|
playedPlayerIDs := map[int]bool{}
|
||||||
playerStatsByID := map[int]*FixtureResultPlayerStats{}
|
playerStatsByID := map[int]*FixtureResultPlayerStats{}
|
||||||
|
freeAgentPlayerIDs := map[int]bool{}
|
||||||
|
// Track free agents by team side for roster inclusion
|
||||||
|
freeAgentsByTeam := map[string]map[int]*FixtureResultPlayerStats{} // "home"/"away" -> playerID -> stats
|
||||||
|
freeAgentsByTeam["home"] = map[int]*FixtureResultPlayerStats{}
|
||||||
|
freeAgentsByTeam["away"] = map[int]*FixtureResultPlayerStats{}
|
||||||
|
|
||||||
if result != nil {
|
if result != nil {
|
||||||
for _, ps := range result.PlayerStats {
|
for _, ps := range result.PlayerStats {
|
||||||
if ps.PlayerID != nil {
|
if ps.PlayerID != nil {
|
||||||
@@ -496,10 +504,29 @@ func GetFixtureTeamRosters(
|
|||||||
if ps.PeriodNum == 3 {
|
if ps.PeriodNum == 3 {
|
||||||
playerStatsByID[*ps.PlayerID] = ps
|
playerStatsByID[*ps.PlayerID] = ps
|
||||||
}
|
}
|
||||||
|
if ps.IsFreeAgent {
|
||||||
|
freeAgentPlayerIDs[*ps.PlayerID] = true
|
||||||
|
if ps.PeriodNum == 3 {
|
||||||
|
freeAgentsByTeam[ps.Team][*ps.PlayerID] = ps
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a set of roster player IDs so we can skip them when adding free agents
|
||||||
|
rosterPlayerIDs := map[int]bool{}
|
||||||
|
for _, r := range homeRosters {
|
||||||
|
if r.Player != nil {
|
||||||
|
rosterPlayerIDs[r.Player.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range awayRosters {
|
||||||
|
if r.Player != nil {
|
||||||
|
rosterPlayerIDs[r.Player.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build home roster with play status and stats
|
// Build home roster with play status and stats
|
||||||
for _, r := range homeRosters {
|
for _, r := range homeRosters {
|
||||||
played := false
|
played := false
|
||||||
@@ -532,5 +559,29 @@ func GetFixtureTeamRosters(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add free agents who played but are not on the team roster
|
||||||
|
for team, faStats := range freeAgentsByTeam {
|
||||||
|
for playerID, stats := range faStats {
|
||||||
|
if rosterPlayerIDs[playerID] {
|
||||||
|
continue // Already on the roster, skip
|
||||||
|
}
|
||||||
|
if stats.Player == nil {
|
||||||
|
// Try to load the player
|
||||||
|
player, err := GetPlayer(ctx, tx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip if we can't load
|
||||||
|
}
|
||||||
|
stats.Player = player
|
||||||
|
}
|
||||||
|
rosters[team] = append(rosters[team], &PlayerWithPlayStatus{
|
||||||
|
Player: stats.Player,
|
||||||
|
Played: true,
|
||||||
|
IsManager: false,
|
||||||
|
IsFreeAgent: true,
|
||||||
|
Stats: stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return rosters, nil
|
return rosters, nil
|
||||||
}
|
}
|
||||||
|
|||||||
342
internal/db/freeagent.go
Normal file
342
internal/db/freeagent.go
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeasonLeagueFreeAgent tracks players registered as free agents in a season_league.
|
||||||
|
type SeasonLeagueFreeAgent struct {
|
||||||
|
bun.BaseModel `bun:"table:season_league_free_agents,alias:slfa"`
|
||||||
|
|
||||||
|
SeasonID int `bun:",pk,notnull"`
|
||||||
|
LeagueID int `bun:",pk,notnull"`
|
||||||
|
PlayerID int `bun:",pk,notnull"`
|
||||||
|
RegisteredAt int64 `bun:",notnull"`
|
||||||
|
RegisteredByUserID int `bun:",notnull"`
|
||||||
|
|
||||||
|
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
|
||||||
|
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||||
|
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
|
||||||
|
RegisteredBy *User `bun:"rel:belongs-to,join:registered_by_user_id=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FixtureFreeAgent tracks which free agents are nominated for specific fixtures.
|
||||||
|
type FixtureFreeAgent struct {
|
||||||
|
bun.BaseModel `bun:"table:fixture_free_agents,alias:ffa"`
|
||||||
|
|
||||||
|
FixtureID int `bun:",pk,notnull"`
|
||||||
|
PlayerID int `bun:",pk,notnull"`
|
||||||
|
TeamID int `bun:",notnull"`
|
||||||
|
NominatedByUserID int `bun:",notnull"`
|
||||||
|
NominatedAt int64 `bun:",notnull"`
|
||||||
|
|
||||||
|
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
|
||||||
|
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
|
||||||
|
Team *Team `bun:"rel:belongs-to,join:team_id=id"`
|
||||||
|
NominatedBy *User `bun:"rel:belongs-to,join:nominated_by_user_id=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterFreeAgent registers a player as a free agent in a season_league.
|
||||||
|
func RegisterFreeAgent(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID, playerID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
user := CurrentUser(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &SeasonLeagueFreeAgent{
|
||||||
|
SeasonID: seasonID,
|
||||||
|
LeagueID: leagueID,
|
||||||
|
PlayerID: playerID,
|
||||||
|
RegisteredAt: time.Now().Unix(),
|
||||||
|
RegisteredByUserID: user.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: "free_agents.add",
|
||||||
|
ResourceType: "season_league_free_agent",
|
||||||
|
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
|
||||||
|
Details: map[string]any{
|
||||||
|
"season_id": seasonID,
|
||||||
|
"league_id": leagueID,
|
||||||
|
"player_id": playerID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Insert")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterFreeAgent removes a player's free agent registration and all their nominations.
|
||||||
|
func UnregisterFreeAgent(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID, playerID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
// First remove all nominations for this player
|
||||||
|
err := RemoveAllFreeAgentNominationsForPlayer(ctx, tx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "RemoveAllFreeAgentNominationsForPlayer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then remove the registration
|
||||||
|
_, err = tx.NewDelete().
|
||||||
|
Model((*SeasonLeagueFreeAgent)(nil)).
|
||||||
|
Where("season_id = ?", seasonID).
|
||||||
|
Where("league_id = ?", leagueID).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: "free_agents.remove",
|
||||||
|
ResourceType: "season_league_free_agent",
|
||||||
|
ResourceID: fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
|
||||||
|
Details: map[string]any{
|
||||||
|
"season_id": seasonID,
|
||||||
|
"league_id": leagueID,
|
||||||
|
"player_id": playerID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err = LogSuccess(ctx, tx, audit, info)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "LogSuccess")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFreeAgentsForSeasonLeague returns all players registered as free agents in a season_league.
|
||||||
|
func GetFreeAgentsForSeasonLeague(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID int,
|
||||||
|
) ([]*SeasonLeagueFreeAgent, error) {
|
||||||
|
entries := []*SeasonLeagueFreeAgent{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&entries).
|
||||||
|
Where("slfa.season_id = ?", seasonID).
|
||||||
|
Where("slfa.league_id = ?", leagueID).
|
||||||
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Relation("User")
|
||||||
|
}).
|
||||||
|
Relation("RegisteredBy").
|
||||||
|
Order("slfa.registered_at ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFreeAgentRegistered checks if a player is registered as a free agent in a season_league.
|
||||||
|
func IsFreeAgentRegistered(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
seasonID, leagueID, playerID int,
|
||||||
|
) (bool, error) {
|
||||||
|
count, err := tx.NewSelect().
|
||||||
|
Model((*SeasonLeagueFreeAgent)(nil)).
|
||||||
|
Where("season_id = ?", seasonID).
|
||||||
|
Where("league_id = ?", leagueID).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NominateFreeAgent nominates a free agent for a specific fixture on behalf of a team.
|
||||||
|
func NominateFreeAgent(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixtureID, playerID, teamID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
user := CurrentUser(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already nominated by another team
|
||||||
|
existing := new(FixtureFreeAgent)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(existing).
|
||||||
|
Where("ffa.fixture_id = ?", fixtureID).
|
||||||
|
Where("ffa.player_id = ?", playerID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err == nil {
|
||||||
|
// Found existing nomination
|
||||||
|
if existing.TeamID != teamID {
|
||||||
|
return BadRequest("Player already nominated for this fixture by another team")
|
||||||
|
}
|
||||||
|
return BadRequest("Player already nominated for this fixture")
|
||||||
|
}
|
||||||
|
if err.Error() != "sql: no rows in result set" {
|
||||||
|
return errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max 2 free agents per team per fixture
|
||||||
|
count, err := tx.NewSelect().
|
||||||
|
Model((*FixtureFreeAgent)(nil)).
|
||||||
|
Where("fixture_id = ?", fixtureID).
|
||||||
|
Where("team_id = ?", teamID).
|
||||||
|
Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewSelect count")
|
||||||
|
}
|
||||||
|
if count >= 2 {
|
||||||
|
return BadRequest("Maximum of 2 free agents per team per fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &FixtureFreeAgent{
|
||||||
|
FixtureID: fixtureID,
|
||||||
|
PlayerID: playerID,
|
||||||
|
TeamID: teamID,
|
||||||
|
NominatedByUserID: user.ID,
|
||||||
|
NominatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: "free_agents.nominate",
|
||||||
|
ResourceType: "fixture_free_agent",
|
||||||
|
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
|
||||||
|
Details: map[string]any{
|
||||||
|
"fixture_id": fixtureID,
|
||||||
|
"player_id": playerID,
|
||||||
|
"team_id": teamID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Insert(tx, entry).WithAudit(audit, info).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Insert")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNominatedFreeAgents returns all free agents nominated for a fixture.
|
||||||
|
func GetNominatedFreeAgents(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixtureID int,
|
||||||
|
) ([]*FixtureFreeAgent, error) {
|
||||||
|
entries := []*FixtureFreeAgent{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&entries).
|
||||||
|
Where("ffa.fixture_id = ?", fixtureID).
|
||||||
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Relation("User")
|
||||||
|
}).
|
||||||
|
Relation("Team").
|
||||||
|
Relation("NominatedBy").
|
||||||
|
Order("ffa.nominated_at ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNominatedFreeAgentsByTeam returns free agents nominated by a specific team for a fixture.
|
||||||
|
func GetNominatedFreeAgentsByTeam(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixtureID, teamID int,
|
||||||
|
) ([]*FixtureFreeAgent, error) {
|
||||||
|
entries := []*FixtureFreeAgent{}
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&entries).
|
||||||
|
Where("ffa.fixture_id = ?", fixtureID).
|
||||||
|
Where("ffa.team_id = ?", teamID).
|
||||||
|
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return q.Relation("User")
|
||||||
|
}).
|
||||||
|
Order("ffa.nominated_at ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAllFreeAgentNominationsForPlayer deletes all nominations for a player.
|
||||||
|
// Used for cascade deletion on team join and unregister.
|
||||||
|
func RemoveAllFreeAgentNominationsForPlayer(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
playerID int,
|
||||||
|
) error {
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*FixtureFreeAgent)(nil)).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFreeAgentNomination removes a specific nomination.
|
||||||
|
func RemoveFreeAgentNomination(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
fixtureID, playerID int,
|
||||||
|
audit *AuditMeta,
|
||||||
|
) error {
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*FixtureFreeAgent)(nil)).
|
||||||
|
Where("fixture_id = ?", fixtureID).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &AuditInfo{
|
||||||
|
Action: "free_agents.remove_nomination",
|
||||||
|
ResourceType: "fixture_free_agent",
|
||||||
|
ResourceID: fmt.Sprintf("%d-%d", fixtureID, playerID),
|
||||||
|
Details: map[string]any{
|
||||||
|
"fixture_id": fixtureID,
|
||||||
|
"player_id": playerID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err = LogSuccess(ctx, tx, audit, info)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "LogSuccess")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFreeAgentRegistrationForPlayer removes all free agent registrations for a player.
|
||||||
|
// Used on team join.
|
||||||
|
func RemoveFreeAgentRegistrationForPlayer(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
playerID int,
|
||||||
|
) error {
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*SeasonLeagueFreeAgent)(nil)).
|
||||||
|
Where("player_id = ?", playerID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
91
internal/db/migrations/20260222140000_free_agents.go
Normal file
91
internal/db/migrations/20260222140000_free_agents.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Migrations.MustRegister(
|
||||||
|
// UP migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
// Create season_league_free_agents table
|
||||||
|
_, err := conn.NewCreateTable().
|
||||||
|
Model((*db.SeasonLeagueFreeAgent)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
ForeignKey(`("season_id") REFERENCES "seasons" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("league_id") REFERENCES "leagues" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("registered_by_user_id") REFERENCES "users" ("id")`).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create fixture_free_agents table
|
||||||
|
_, err = conn.NewCreateTable().
|
||||||
|
Model((*db.FixtureFreeAgent)(nil)).
|
||||||
|
IfNotExists().
|
||||||
|
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("player_id") REFERENCES "players" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("team_id") REFERENCES "teams" ("id") ON DELETE CASCADE`).
|
||||||
|
ForeignKey(`("nominated_by_user_id") REFERENCES "users" ("id")`).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index on fixture_free_agents for team lookups
|
||||||
|
_, err = conn.NewCreateIndex().
|
||||||
|
Model((*db.FixtureFreeAgent)(nil)).
|
||||||
|
Index("idx_ffa_fixture_team").
|
||||||
|
Column("fixture_id", "team_id").
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add is_free_agent column to fixture_result_player_stats
|
||||||
|
_, err = conn.NewAddColumn().
|
||||||
|
Model((*db.FixtureResultPlayerStats)(nil)).
|
||||||
|
ColumnExpr("is_free_agent BOOLEAN NOT NULL DEFAULT false").
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// DOWN migration
|
||||||
|
func(ctx context.Context, conn *bun.DB) error {
|
||||||
|
// Drop is_free_agent column from fixture_result_player_stats
|
||||||
|
_, err := conn.NewDropColumn().
|
||||||
|
Model((*db.FixtureResultPlayerStats)(nil)).
|
||||||
|
ColumnExpr("is_free_agent").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop fixture_free_agents table
|
||||||
|
_, err = conn.NewDropTable().
|
||||||
|
Model((*db.FixtureFreeAgent)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop season_league_free_agents table
|
||||||
|
_, err = conn.NewDropTable().
|
||||||
|
Model((*db.SeasonLeagueFreeAgent)(nil)).
|
||||||
|
IfExists().
|
||||||
|
Exec(ctx)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -128,6 +128,7 @@ func AddPlayerToTeam(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID,
|
|||||||
|
|
||||||
// ManageTeamRoster replaces the entire roster for a team in a season/league.
|
// ManageTeamRoster replaces the entire roster for a team in a season/league.
|
||||||
// It deletes all existing roster entries and inserts the new ones.
|
// It deletes all existing roster entries and inserts the new ones.
|
||||||
|
// Also auto-removes free agent registrations and nominations for players joining a team.
|
||||||
func ManageTeamRoster(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, managerID int, playerIDs []int, audit *AuditMeta) error {
|
func ManageTeamRoster(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID, managerID int, playerIDs []int, audit *AuditMeta) error {
|
||||||
// Delete all existing roster entries for this team/season/league
|
// Delete all existing roster entries for this team/season/league
|
||||||
_, err := tx.NewDelete().
|
_, err := tx.NewDelete().
|
||||||
@@ -140,6 +141,56 @@ func ManageTeamRoster(ctx context.Context, tx bun.Tx, seasonID, leagueID, teamID
|
|||||||
return errors.Wrap(err, "delete existing roster")
|
return errors.Wrap(err, "delete existing roster")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect all player IDs being added (including manager)
|
||||||
|
allPlayerIDs := make([]int, 0, len(playerIDs)+1)
|
||||||
|
if managerID > 0 {
|
||||||
|
allPlayerIDs = append(allPlayerIDs, managerID)
|
||||||
|
}
|
||||||
|
for _, pid := range playerIDs {
|
||||||
|
if pid != managerID {
|
||||||
|
allPlayerIDs = append(allPlayerIDs, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-remove free agent registrations and nominations for players joining a team
|
||||||
|
for _, playerID := range allPlayerIDs {
|
||||||
|
// Check if the player is a registered free agent
|
||||||
|
isFA, err := IsFreeAgentRegistered(ctx, tx, seasonID, leagueID, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "IsFreeAgentRegistered")
|
||||||
|
}
|
||||||
|
if isFA {
|
||||||
|
// Remove all nominations for this player
|
||||||
|
err = RemoveAllFreeAgentNominationsForPlayer(ctx, tx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "RemoveAllFreeAgentNominationsForPlayer")
|
||||||
|
}
|
||||||
|
// Remove free agent registration
|
||||||
|
err = RemoveFreeAgentRegistrationForPlayer(ctx, tx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "RemoveFreeAgentRegistrationForPlayer")
|
||||||
|
}
|
||||||
|
// Log the cascade action
|
||||||
|
if audit != nil {
|
||||||
|
cascadeInfo := &AuditInfo{
|
||||||
|
"free_agents.auto_removed_on_team_join",
|
||||||
|
"season_league_free_agent",
|
||||||
|
fmt.Sprintf("%d-%d-%d", seasonID, leagueID, playerID),
|
||||||
|
map[string]any{
|
||||||
|
"season_id": seasonID,
|
||||||
|
"league_id": leagueID,
|
||||||
|
"player_id": playerID,
|
||||||
|
"team_id": teamID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err = LogSuccess(ctx, tx, audit, cascadeInfo)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "LogSuccess cascade")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Insert manager if provided
|
// Insert manager if provided
|
||||||
if managerID > 0 {
|
if managerID > 0 {
|
||||||
tr := &TeamRoster{
|
tr := &TeamRoster{
|
||||||
|
|||||||
@@ -52,7 +52,6 @@
|
|||||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
--animate-spin: spin 1s linear infinite;
|
--animate-spin: spin 1s linear infinite;
|
||||||
--blur-sm: 8px;
|
|
||||||
--default-transition-duration: 150ms;
|
--default-transition-duration: 150ms;
|
||||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--default-font-family: var(--font-sans);
|
--default-font-family: var(--font-sans);
|
||||||
@@ -1306,6 +1305,12 @@
|
|||||||
.text-yellow {
|
.text-yellow {
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
}
|
}
|
||||||
|
.text-yellow\/60 {
|
||||||
|
color: var(--yellow);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
color: color-mix(in oklab, var(--yellow) 60%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
.text-yellow\/70 {
|
.text-yellow\/70 {
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1367,11 +1372,6 @@
|
|||||||
.filter {
|
.filter {
|
||||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||||
}
|
}
|
||||||
.backdrop-blur-sm {
|
|
||||||
--tw-backdrop-blur: blur(var(--blur-sm));
|
|
||||||
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
|
||||||
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
|
||||||
}
|
|
||||||
.transition {
|
.transition {
|
||||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
@@ -1572,6 +1572,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-peach\/75 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--peach);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--peach) 75%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-peach\/80 {
|
.hover\:bg-peach\/80 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -1592,6 +1602,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-red\/40 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--red);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--red) 40%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-red\/75 {
|
.hover\:bg-red\/75 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -1872,6 +1892,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled\:bg-peach\/40 {
|
||||||
|
&:disabled {
|
||||||
|
background-color: var(--peach);
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
background-color: color-mix(in oklab, var(--peach) 40%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.disabled\:opacity-50 {
|
.disabled\:opacity-50 {
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 50%;
|
opacity: 50%;
|
||||||
@@ -2491,42 +2519,6 @@
|
|||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
}
|
}
|
||||||
@property --tw-backdrop-blur {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
@property --tw-backdrop-brightness {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
@property --tw-backdrop-contrast {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
@property --tw-backdrop-grayscale {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
@property --tw-backdrop-hue-rotate {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
@property --tw-backdrop-invert {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
@property --tw-backdrop-opacity {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
@property --tw-backdrop-saturate {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
@property --tw-backdrop-sepia {
|
|
||||||
syntax: "*";
|
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
@property --tw-duration {
|
@property --tw-duration {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@@ -2589,15 +2581,6 @@
|
|||||||
--tw-drop-shadow-color: initial;
|
--tw-drop-shadow-color: initial;
|
||||||
--tw-drop-shadow-alpha: 100%;
|
--tw-drop-shadow-alpha: 100%;
|
||||||
--tw-drop-shadow-size: initial;
|
--tw-drop-shadow-size: initial;
|
||||||
--tw-backdrop-blur: initial;
|
|
||||||
--tw-backdrop-brightness: initial;
|
|
||||||
--tw-backdrop-contrast: initial;
|
|
||||||
--tw-backdrop-grayscale: initial;
|
|
||||||
--tw-backdrop-hue-rotate: initial;
|
|
||||||
--tw-backdrop-invert: initial;
|
|
||||||
--tw-backdrop-opacity: initial;
|
|
||||||
--tw-backdrop-saturate: initial;
|
|
||||||
--tw-backdrop-sepia: initial;
|
|
||||||
--tw-duration: initial;
|
--tw-duration: initial;
|
||||||
--tw-ease: initial;
|
--tw-ease: initial;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
"git.haelnorr.com/h/oslstats/internal/respond"
|
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
"git.haelnorr.com/h/oslstats/internal/validation"
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
@@ -43,6 +45,8 @@ func FixtureDetailPage(
|
|||||||
var userTeamID int
|
var userTeamID int
|
||||||
var result *db.FixtureResult
|
var result *db.FixtureResult
|
||||||
var rosters map[string][]*db.PlayerWithPlayStatus
|
var rosters map[string][]*db.PlayerWithPlayStatus
|
||||||
|
var nominatedFreeAgents []*db.FixtureFreeAgent
|
||||||
|
var availableFreeAgents []*db.SeasonLeagueFreeAgent
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
@@ -77,6 +81,19 @@ func FixtureDetailPage(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
|
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
|
||||||
}
|
}
|
||||||
|
// Fetch free agent nominations for this fixture
|
||||||
|
nominatedFreeAgents, err = db.GetNominatedFreeAgents(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
|
||||||
|
}
|
||||||
|
// Fetch available free agents for nomination (if user can schedule or manage fixtures)
|
||||||
|
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
|
||||||
|
if canSchedule || canManage {
|
||||||
|
availableFreeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
|
||||||
|
}
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}); !ok {
|
}); !ok {
|
||||||
return
|
return
|
||||||
@@ -84,7 +101,7 @@ func FixtureDetailPage(
|
|||||||
|
|
||||||
renderSafely(seasonsview.FixtureDetailPage(
|
renderSafely(seasonsview.FixtureDetailPage(
|
||||||
fixture, currentSchedule, history, canSchedule, userTeamID,
|
fixture, currentSchedule, history, canSchedule, userTeamID,
|
||||||
result, rosters, activeTab,
|
result, rosters, activeTab, nominatedFreeAgents, availableFreeAgents,
|
||||||
), s, r, w)
|
), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -108,7 +125,8 @@ func ProposeSchedule(
|
|||||||
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
||||||
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
||||||
aest, _ := time.LoadLocation("Australia/Sydney")
|
aest, _ := time.LoadLocation("Australia/Sydney")
|
||||||
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
|
// scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
|
||||||
|
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).Value
|
||||||
|
|
||||||
if !getter.ValidateAndNotify(s, w, r) {
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
return
|
return
|
||||||
@@ -147,7 +165,7 @@ func ProposeSchedule(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notify.Success(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil)
|
notify.SuccessWithDelay(s, w, r, "Time Proposed", "Your proposed time has been submitted.", nil)
|
||||||
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,6 +282,21 @@ func UploadMatchLogs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check each player stat: if the player is a registered free agent, mark them
|
||||||
|
for _, ps := range playerStats {
|
||||||
|
if ps.PlayerID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if the player is a registered free agent
|
||||||
|
isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
|
||||||
|
}
|
||||||
|
if isFA {
|
||||||
|
ps.IsFreeAgent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Insert result and stats
|
// Insert result and stats
|
||||||
result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r))
|
result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -294,7 +309,6 @@ func UploadMatchLogs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ = unmappedPlayers // stored for review page redirect
|
_ = unmappedPlayers // stored for review page redirect
|
||||||
notify.Success(s, w, r, "Logs Uploaded", "Match logs have been processed. Please review the result.", nil)
|
|
||||||
respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID)
|
respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -314,6 +328,7 @@ func ReviewMatchResult(
|
|||||||
var fixture *db.Fixture
|
var fixture *db.Fixture
|
||||||
var result *db.FixtureResult
|
var result *db.FixtureResult
|
||||||
var unmappedPlayers []string
|
var unmappedPlayers []string
|
||||||
|
var faWarnings []seasonsview.FreeAgentWarning
|
||||||
|
|
||||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
@@ -335,11 +350,52 @@ func ReviewMatchResult(
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build unmapped players list from stats
|
// Get nominated free agents for this fixture
|
||||||
|
nominatedFAs, err := db.GetNominatedFreeAgents(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetNominatedFreeAgents")
|
||||||
|
}
|
||||||
|
// Map player ID to the side ("home"/"away") that nominated them
|
||||||
|
nominatedFASide := map[int]string{}
|
||||||
|
for _, nfa := range nominatedFAs {
|
||||||
|
if nfa.TeamID == fixture.HomeTeamID {
|
||||||
|
nominatedFASide[nfa.PlayerID] = "home"
|
||||||
|
} else {
|
||||||
|
nominatedFASide[nfa.PlayerID] = "away"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to resolve side to team name
|
||||||
|
teamNameForSide := func(side string) string {
|
||||||
|
if side == "home" {
|
||||||
|
return fixture.HomeTeam.Name
|
||||||
|
}
|
||||||
|
return fixture.AwayTeam.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build unmapped players and free agent warnings from stats
|
||||||
|
seen := map[int]bool{}
|
||||||
for _, ps := range result.PlayerStats {
|
for _, ps := range result.PlayerStats {
|
||||||
if ps.PlayerID == nil && ps.PeriodNum == 3 {
|
if ps.PeriodNum != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ps.PlayerID == nil {
|
||||||
unmappedPlayers = append(unmappedPlayers,
|
unmappedPlayers = append(unmappedPlayers,
|
||||||
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
|
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
|
||||||
|
} else if ps.IsFreeAgent && !seen[*ps.PlayerID] {
|
||||||
|
seen[*ps.PlayerID] = true
|
||||||
|
nominatedSide, wasNominated := nominatedFASide[*ps.PlayerID]
|
||||||
|
if !wasNominated {
|
||||||
|
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
|
||||||
|
Name: ps.PlayerUsername,
|
||||||
|
Reason: "not nominated for this fixture",
|
||||||
|
})
|
||||||
|
} else if nominatedSide != ps.Team {
|
||||||
|
faWarnings = append(faWarnings, seasonsview.FreeAgentWarning{
|
||||||
|
Name: ps.PlayerUsername,
|
||||||
|
Reason: "nominated by " + teamNameForSide(nominatedSide) + ", but played for " + teamNameForSide(ps.Team),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +404,7 @@ func ReviewMatchResult(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers), s, r, w)
|
renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers, faWarnings), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
356
internal/handlers/free_agents.go
Normal file
356
internal/handlers/free_agents.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FreeAgentsListPage renders the free agents tab of a season league page
|
||||||
|
func FreeAgentsListPage(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonStr := r.PathValue("season_short_name")
|
||||||
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
|
var season *db.Season
|
||||||
|
var league *db.League
|
||||||
|
var freeAgents []*db.SeasonLeagueFreeAgent
|
||||||
|
var availablePlayers []*db.Player
|
||||||
|
|
||||||
|
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
var err error
|
||||||
|
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
||||||
|
}
|
||||||
|
season = sl.Season
|
||||||
|
league = sl.League
|
||||||
|
|
||||||
|
freeAgents, err = db.GetFreeAgentsForSeasonLeague(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetFreeAgentsForSeasonLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
availablePlayers, err = db.GetPlayersNotOnTeam(ctx, tx, season.ID, league.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out players already registered as free agents
|
||||||
|
faMap := make(map[int]bool, len(freeAgents))
|
||||||
|
for _, fa := range freeAgents {
|
||||||
|
faMap[fa.PlayerID] = true
|
||||||
|
}
|
||||||
|
filtered := make([]*db.Player, 0, len(availablePlayers))
|
||||||
|
for _, p := range availablePlayers {
|
||||||
|
if !faMap[p.ID] {
|
||||||
|
filtered = append(filtered, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
availablePlayers = filtered
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
renderSafely(seasonsview.SeasonLeagueFreeAgentsPage(season, league, freeAgents, availablePlayers), s, r, w)
|
||||||
|
} else {
|
||||||
|
renderSafely(seasonsview.SeasonLeagueFreeAgents(season, league, freeAgents, availablePlayers), s, r, w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterFreeAgent handles POST to register a player as a free agent
|
||||||
|
func RegisterFreeAgent(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonStr := r.PathValue("season_short_name")
|
||||||
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
respond.BadRequest(w, errors.New("failed to parse form"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := getter.Int("player_id").Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
respond.BadRequest(w, errors.New("invalid form data"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify player is not on a team in this season_league
|
||||||
|
players, err := db.GetPlayersNotOnTeam(ctx, tx, sl.Season.ID, sl.League.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetPlayersNotOnTeam")
|
||||||
|
}
|
||||||
|
playerFound := false
|
||||||
|
for _, p := range players {
|
||||||
|
if p.ID == playerID {
|
||||||
|
playerFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !playerFound {
|
||||||
|
notify.Warn(s, w, r, "Cannot Register", "Player is already on a team in this league.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already registered
|
||||||
|
isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, sl.Season.ID, sl.League.ID, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
|
||||||
|
}
|
||||||
|
if isRegistered {
|
||||||
|
notify.Warn(s, w, r, "Already Registered", "Player is already registered as a free agent.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.RegisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.RegisterFreeAgent")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Free Agent Registered", "Player has been registered as a free agent.", nil)
|
||||||
|
respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterFreeAgent handles POST to unregister a player as a free agent
|
||||||
|
func UnregisterFreeAgent(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seasonStr := r.PathValue("season_short_name")
|
||||||
|
leagueStr := r.PathValue("league_short_name")
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
respond.BadRequest(w, errors.New("failed to parse form"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := getter.Int("player_id").Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
respond.BadRequest(w, errors.New("invalid form data"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
sl, err := db.GetSeasonLeague(ctx, tx, seasonStr, leagueStr)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
throw.NotFound(s, w, r, r.URL.Path)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetSeasonLeague")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.UnregisterFreeAgent(ctx, tx, sl.Season.ID, sl.League.ID, playerID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.UnregisterFreeAgent")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Free Agent Removed", "Player has been unregistered as a free agent.", nil)
|
||||||
|
respond.HXRedirect(w, "/seasons/%s/leagues/%s/free-agents", seasonStr, leagueStr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NominateFreeAgentHandler handles POST to nominate a free agent for a fixture
|
||||||
|
func NominateFreeAgentHandler(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := getter.Int("player_id").Required().Value
|
||||||
|
teamID := getter.Int("team_id").Required().Value
|
||||||
|
if !getter.ValidateAndNotify(s, w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
// Verify fixture exists and user is a manager
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can nominate: either a manager of the nominating team,
|
||||||
|
// or has fixtures.manage permission (can nominate for either team)
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
|
||||||
|
if !canManage {
|
||||||
|
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule || userTeamID != teamID {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a manager of the nominating team", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Verify the team_id is actually one of the fixture's teams
|
||||||
|
if teamID != fixture.HomeTeamID && teamID != fixture.AwayTeamID {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid team for this fixture", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify player is a registered free agent in this season_league
|
||||||
|
isRegistered, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
|
||||||
|
}
|
||||||
|
if !isRegistered {
|
||||||
|
notify.Warn(s, w, r, "Not Registered", "Player is not a registered free agent in this league.", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.NominateFreeAgent(ctx, tx, fixtureID, playerID, teamID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
notify.Warn(s, w, r, "Cannot Nominate", err.Error(), nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.NominateFreeAgent")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Free Agent Nominated", "Free agent has been nominated for this fixture.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFreeAgentNominationHandler handles POST to remove a free agent nomination
|
||||||
|
func RemoveFreeAgentNominationHandler(
|
||||||
|
s *hws.Server,
|
||||||
|
conn *db.DB,
|
||||||
|
) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(r.PathValue("player_id"))
|
||||||
|
if err != nil {
|
||||||
|
throw.BadRequest(s, w, r, "Invalid player ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
// Check if user can remove: either has fixtures.manage permission,
|
||||||
|
// or is a manager of the team that nominated the free agent
|
||||||
|
canManage := contexts.Permissions(ctx).HasPermission(permissions.FixturesManage)
|
||||||
|
if !canManage {
|
||||||
|
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||||
|
if err != nil {
|
||||||
|
if db.IsBadRequest(err) {
|
||||||
|
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.Wrap(err, "db.GetFixture")
|
||||||
|
}
|
||||||
|
user := db.CurrentUser(ctx)
|
||||||
|
canSchedule, userTeamID, err := fixture.CanSchedule(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||||
|
}
|
||||||
|
if !canSchedule {
|
||||||
|
throw.Forbidden(s, w, r, "You must be a team manager to remove nominations", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
// Verify the nomination belongs to the user's team
|
||||||
|
nominations, err := db.GetNominatedFreeAgentsByTeam(ctx, tx, fixtureID, userTeamID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.GetNominatedFreeAgentsByTeam")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, n := range nominations {
|
||||||
|
if n.PlayerID == playerID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
throw.Forbidden(s, w, r, "You can only remove nominations made by your team", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.RemoveFreeAgentNomination(ctx, tx, fixtureID, playerID, db.NewAuditFromRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "db.RemoveFreeAgentNomination")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.Success(s, w, r, "Nomination Removed", "Free agent nomination has been removed.", nil)
|
||||||
|
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -39,4 +39,8 @@ const (
|
|||||||
FixturesManage Permission = "fixtures.manage"
|
FixturesManage Permission = "fixtures.manage"
|
||||||
FixturesCreate Permission = "fixtures.create"
|
FixturesCreate Permission = "fixtures.create"
|
||||||
FixturesDelete Permission = "fixtures.delete"
|
FixturesDelete Permission = "fixtures.delete"
|
||||||
|
|
||||||
|
// Free Agent permissions
|
||||||
|
FreeAgentsAdd Permission = "free_agents.add"
|
||||||
|
FreeAgentsRemove Permission = "free_agents.remove"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -147,6 +147,22 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn)),
|
Handler: perms.RequirePermission(s, permissions.TeamsAddToLeague)(handlers.SeasonLeagueAddTeam(s, conn)),
|
||||||
},
|
},
|
||||||
|
// Free agent routes
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.FreeAgentsListPage(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents/register",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FreeAgentsAdd)(handlers.RegisterFreeAgent(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/seasons/{season_short_name}/leagues/{league_short_name}/free-agents/unregister",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequirePermission(s, permissions.FreeAgentsRemove)(handlers.UnregisterFreeAgent(s, conn)),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
leagueRoutes := []hws.Route{
|
leagueRoutes := []hws.Route{
|
||||||
@@ -234,6 +250,17 @@ func addRoutes(
|
|||||||
Method: hws.MethodPOST,
|
Method: hws.MethodPOST,
|
||||||
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.CancelSchedule(s, conn)),
|
Handler: perms.RequirePermission(s, permissions.FixturesManage)(handlers.CancelSchedule(s, conn)),
|
||||||
},
|
},
|
||||||
|
// Fixture free agent nomination routes
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/free-agents/nominate",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.NominateFreeAgentHandler(s, conn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/fixtures/{fixture_id}/free-agents/{player_id}/remove",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: handlers.RemoveFreeAgentNominationHandler(s, conn),
|
||||||
|
},
|
||||||
// Match result management routes (all require fixtures.manage permission)
|
// Match result management routes (all require fixtures.manage permission)
|
||||||
{
|
{
|
||||||
Path: "/fixtures/{fixture_id}/results/upload",
|
Path: "/fixtures/{fixture_id}/results/upload",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ templ FixtureDetailPage(
|
|||||||
result *db.FixtureResult,
|
result *db.FixtureResult,
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
activeTab string,
|
activeTab string,
|
||||||
|
nominatedFreeAgents []*db.FixtureFreeAgent,
|
||||||
|
availableFreeAgents []*db.SeasonLeagueFreeAgent,
|
||||||
) {
|
) {
|
||||||
{{
|
{{
|
||||||
permCache := contexts.Permissions(ctx)
|
permCache := contexts.Permissions(ctx)
|
||||||
@@ -78,10 +80,10 @@ templ FixtureDetailPage(
|
|||||||
</nav>
|
</nav>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
if activeTab == "overview" {
|
if activeTab == "overview" {
|
||||||
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage)
|
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
|
||||||
} else if activeTab == "schedule" {
|
} else if activeTab == "schedule" {
|
||||||
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
|
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +118,10 @@ templ fixtureOverviewTab(
|
|||||||
result *db.FixtureResult,
|
result *db.FixtureResult,
|
||||||
rosters map[string][]*db.PlayerWithPlayStatus,
|
rosters map[string][]*db.PlayerWithPlayStatus,
|
||||||
canManage bool,
|
canManage bool,
|
||||||
|
canSchedule bool,
|
||||||
|
userTeamID int,
|
||||||
|
nominatedFreeAgents []*db.FixtureFreeAgent,
|
||||||
|
availableFreeAgents []*db.SeasonLeagueFreeAgent,
|
||||||
) {
|
) {
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Result + Schedule Row -->
|
<!-- Result + Schedule Row -->
|
||||||
@@ -135,6 +141,10 @@ templ fixtureOverviewTab(
|
|||||||
@fixtureScheduleSummary(fixture, currentSchedule, result)
|
@fixtureScheduleSummary(fixture, currentSchedule, result)
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Team Rosters -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result)
|
@fixtureTeamSection(fixture.HomeTeam, rosters["home"], "home", result)
|
||||||
@@ -400,6 +410,11 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
★
|
★
|
||||||
</span>
|
</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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
if p.Stats != nil {
|
if p.Stats != nil {
|
||||||
@@ -456,6 +471,11 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
★ Manager
|
★ Manager
|
||||||
</span>
|
</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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -482,6 +502,256 @@ templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side
|
|||||||
</div>
|
</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 ====================
|
// ==================== Schedule Tab ====================
|
||||||
templ fixtureScheduleTab(
|
templ fixtureScheduleTab(
|
||||||
fixture *db.Fixture,
|
fixture *db.Fixture,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ templ FixtureReviewResultPage(
|
|||||||
fixture *db.Fixture,
|
fixture *db.Fixture,
|
||||||
result *db.FixtureResult,
|
result *db.FixtureResult,
|
||||||
unmappedPlayers []string,
|
unmappedPlayers []string,
|
||||||
|
unnominatedFreeAgents []FreeAgentWarning,
|
||||||
) {
|
) {
|
||||||
{{
|
{{
|
||||||
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
backURL := fmt.Sprintf("/fixtures/%d", fixture.ID)
|
||||||
@@ -37,38 +38,56 @@ templ FixtureReviewResultPage(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Warnings Section -->
|
<!-- Warnings Section -->
|
||||||
if result.TamperingDetected || len(unmappedPlayers) > 0 {
|
if result.TamperingDetected || len(unmappedPlayers) > 0 || len(unnominatedFreeAgents) > 0 {
|
||||||
<div class="space-y-4 mb-6">
|
<div class="space-y-4 mb-6">
|
||||||
if result.TamperingDetected && result.TamperingReason != nil {
|
if result.TamperingDetected && result.TamperingReason != nil {
|
||||||
<div class="bg-red/10 border border-red/30 rounded-lg p-4">
|
<div class="bg-red/10 border border-red/30 rounded-lg p-4">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span>
|
<span class="text-red font-bold text-sm">⚠ Inconsistent Data Detected</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
|
<p class="text-red/80 text-sm">{ *result.TamperingReason }</p>
|
||||||
<p class="text-red/60 text-xs mt-2">
|
<p class="text-red/60 text-xs mt-2">
|
||||||
This does not block finalization but should be reviewed carefully.
|
This does not block finalization but should be reviewed carefully.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
}
|
<p class="text-yellow/80 text-sm mb-2">
|
||||||
if len(unmappedPlayers) > 0 {
|
The following free agents have nomination issues that should be reviewed before finalizing.
|
||||||
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4">
|
</p>
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
||||||
<span class="text-yellow font-bold text-sm">⚠ Unmapped Players</span>
|
for _, fa := range unnominatedFreeAgents {
|
||||||
</div>
|
<li>
|
||||||
<p class="text-yellow/80 text-sm mb-2">
|
<span class="text-yellow font-medium">{ fa.Name }</span>
|
||||||
The following players could not be matched to registered players.
|
<span class="text-yellow/60"> — { fa.Reason }</span>
|
||||||
They may be free agents or have unregistered Slapshot IDs.
|
</li>
|
||||||
</p>
|
}
|
||||||
<ul class="list-disc list-inside text-yellow/70 text-xs space-y-0.5">
|
</ul>
|
||||||
for _, p := range unmappedPlayers {
|
</div>
|
||||||
<li>{ p }</li>
|
}
|
||||||
}
|
if len(unmappedPlayers) > 0 {
|
||||||
</ul>
|
<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">
|
||||||
</div>
|
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 -->
|
<!-- Score Overview -->
|
||||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
<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">
|
<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 {
|
for _, ps := range finalStats {
|
||||||
<tr class="hover:bg-surface0 transition-colors">
|
<tr class="hover:bg-surface0 transition-colors">
|
||||||
<td class="px-3 py-2 text-sm text-text">
|
<td class="px-3 py-2 text-sm text-text">
|
||||||
{ ps.Username }
|
<span class="flex items-center gap-1.5">
|
||||||
if ps.PlayerID == nil {
|
{ ps.Username }
|
||||||
<span class="text-yellow text-xs ml-1" title="Unmapped player">?</span>
|
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>
|
||||||
<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.Goals) }</td>
|
||||||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
|
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(ps.Stats.Assists) }</td>
|
||||||
|
|||||||
168
internal/view/seasonsview/season_league_free_agents.templ
Normal file
168
internal/view/seasonsview/season_league_free_agents.templ
Normal 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>
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@ templ SeasonLeagueLayout(activeSection string, season *db.Season, league *db.Lea
|
|||||||
@leagueNavItem("table", "Table", activeSection, season, league)
|
@leagueNavItem("table", "Table", activeSection, season, league)
|
||||||
@leagueNavItem("fixtures", "Fixtures", activeSection, season, league)
|
@leagueNavItem("fixtures", "Fixtures", activeSection, season, league)
|
||||||
@leagueNavItem("teams", "Teams", activeSection, season, league)
|
@leagueNavItem("teams", "Teams", activeSection, season, league)
|
||||||
|
@leagueNavItem("free-agents", "Free Agents", activeSection, season, league)
|
||||||
@leagueNavItem("stats", "Stats", activeSection, season, league)
|
@leagueNavItem("stats", "Stats", activeSection, season, league)
|
||||||
@leagueNavItem("finals", "Finals", activeSection, season, league)
|
@leagueNavItem("finals", "Finals", activeSection, season, league)
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
7
internal/view/seasonsview/types.go
Normal file
7
internal/view/seasonsview/types.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user