1629 lines
58 KiB
Plaintext
1629 lines
58 KiB
Plaintext
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 "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||
import "git.haelnorr.com/h/oslstats/internal/view/component/links"
|
||
import "fmt"
|
||
import "sort"
|
||
import "strings"
|
||
|
||
// FixtureDetailLayout renders the fixture detail page layout with header and
|
||
// tab navigation. Tab content is rendered as children.
|
||
templ FixtureDetailLayout(activeTab string, fixture *db.Fixture, result *db.FixtureResult) {
|
||
{{
|
||
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
|
||
isFinalized := result != nil && result.Finalized
|
||
}}
|
||
@baseview.Layout(fmt.Sprintf("%s vs %s", fixture.HomeTeam.Name, fixture.AwayTeam.Name)) {
|
||
<div class="max-w-screen-lg mx-auto px-4 py-8">
|
||
<!-- Header -->
|
||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden mb-6">
|
||
<div class="bg-surface0 border-b border-surface1 px-6 py-8">
|
||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||
<div>
|
||
<div class="flex items-center gap-4 mb-2">
|
||
<h1 class="text-3xl font-bold text-text">
|
||
{ fixture.HomeTeam.Name }
|
||
<span class="text-subtext0 font-normal">vs</span>
|
||
{ fixture.AwayTeam.Name }
|
||
</h1>
|
||
</div>
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
||
Round { fmt.Sprint(fixture.Round) }
|
||
</span>
|
||
if fixture.GameWeek != nil {
|
||
<span class="px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-mono">
|
||
Game Week { fmt.Sprint(*fixture.GameWeek) }
|
||
</span>
|
||
}
|
||
<span class="text-subtext1 text-sm">
|
||
{ fixture.Season.Name } — { fixture.League.Name }
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<a
|
||
href={ templ.SafeURL(backURL) }
|
||
class="rounded-lg px-4 py-2 hover:cursor-pointer text-center
|
||
bg-surface1 hover:bg-surface2 text-text transition"
|
||
>
|
||
Back to Fixtures
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<!-- Tab Navigation -->
|
||
<nav class="bg-surface0 border-b border-surface1" data-tab-nav="fixture-detail-content">
|
||
<ul class="flex flex-wrap">
|
||
@fixtureTabItem("overview", "Overview", activeTab, fixture)
|
||
if isFinalized {
|
||
@fixtureTabItem("analysis", "Match Analysis", activeTab, fixture)
|
||
} else {
|
||
@fixtureTabItem("preview", "Match Preview", activeTab, fixture)
|
||
@fixtureTabItem("scheduling", "Schedule", activeTab, fixture)
|
||
}
|
||
</ul>
|
||
</nav>
|
||
</div>
|
||
<!-- Content Area -->
|
||
<main id="fixture-detail-content">
|
||
{ children... }
|
||
</main>
|
||
</div>
|
||
<script src="/static/js/tabs.js" defer></script>
|
||
}
|
||
}
|
||
|
||
templ fixtureTabItem(section string, label string, activeTab string, fixture *db.Fixture) {
|
||
{{
|
||
isActive := section == activeTab
|
||
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||
activeClasses := "border-blue text-blue font-semibold"
|
||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||
url := fmt.Sprintf("/fixtures/%d/%s", fixture.ID, section)
|
||
}}
|
||
<li class="inline-block">
|
||
<a
|
||
href={ templ.SafeURL(url) }
|
||
hx-post={ url }
|
||
hx-target="#fixture-detail-content"
|
||
hx-swap="innerHTML"
|
||
hx-push-url={ url }
|
||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||
>
|
||
{ label }
|
||
</a>
|
||
</li>
|
||
}
|
||
|
||
// ==================== Full page wrappers (for GET requests / direct navigation) ====================
|
||
|
||
templ FixtureDetailOverviewPage(
|
||
fixture *db.Fixture,
|
||
currentSchedule *db.FixtureSchedule,
|
||
canSchedule bool,
|
||
userTeamID int,
|
||
result *db.FixtureResult,
|
||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||
nominatedFreeAgents []*db.FixtureFreeAgent,
|
||
availableFreeAgents []*db.SeasonLeagueFreeAgent,
|
||
) {
|
||
@FixtureDetailLayout("overview", fixture, result) {
|
||
@FixtureDetailOverviewContent(fixture, currentSchedule, canSchedule, userTeamID, result, rosters, nominatedFreeAgents, availableFreeAgents)
|
||
}
|
||
}
|
||
|
||
templ FixtureDetailPreviewPage(
|
||
fixture *db.Fixture,
|
||
result *db.FixtureResult,
|
||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||
previewData *db.MatchPreviewData,
|
||
) {
|
||
@FixtureDetailLayout("preview", fixture, result) {
|
||
@FixtureDetailPreviewContent(fixture, rosters, previewData)
|
||
}
|
||
}
|
||
|
||
templ FixtureDetailAnalysisPage(
|
||
fixture *db.Fixture,
|
||
result *db.FixtureResult,
|
||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||
previewData *db.MatchPreviewData,
|
||
) {
|
||
@FixtureDetailLayout("analysis", fixture, result) {
|
||
@FixtureDetailAnalysisContent(fixture, result, rosters, previewData)
|
||
}
|
||
}
|
||
|
||
templ FixtureDetailSchedulePage(
|
||
fixture *db.Fixture,
|
||
currentSchedule *db.FixtureSchedule,
|
||
history []*db.FixtureSchedule,
|
||
canSchedule bool,
|
||
userTeamID int,
|
||
) {
|
||
@FixtureDetailLayout("scheduling", fixture, nil) {
|
||
@FixtureDetailScheduleContent(fixture, currentSchedule, history, canSchedule, userTeamID)
|
||
}
|
||
}
|
||
|
||
// ==================== Tab content components (for POST requests / HTMX swaps) ====================
|
||
|
||
templ FixtureDetailOverviewContent(
|
||
fixture *db.Fixture,
|
||
currentSchedule *db.FixtureSchedule,
|
||
canSchedule bool,
|
||
userTeamID int,
|
||
result *db.FixtureResult,
|
||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||
nominatedFreeAgents []*db.FixtureFreeAgent,
|
||
availableFreeAgents []*db.SeasonLeagueFreeAgent,
|
||
) {
|
||
{{
|
||
permCache := contexts.Permissions(ctx)
|
||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
||
}}
|
||
@fixtureOverviewTab(fixture, currentSchedule, result, rosters, canManage, canSchedule, userTeamID, nominatedFreeAgents, availableFreeAgents)
|
||
}
|
||
|
||
templ FixtureDetailPreviewContent(
|
||
fixture *db.Fixture,
|
||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||
previewData *db.MatchPreviewData,
|
||
) {
|
||
@fixtureMatchPreviewTab(fixture, rosters, previewData)
|
||
}
|
||
|
||
templ FixtureDetailAnalysisContent(
|
||
fixture *db.Fixture,
|
||
result *db.FixtureResult,
|
||
rosters map[string][]*db.PlayerWithPlayStatus,
|
||
previewData *db.MatchPreviewData,
|
||
) {
|
||
@fixtureMatchAnalysisTab(fixture, result, rosters, previewData)
|
||
}
|
||
|
||
templ FixtureDetailScheduleContent(
|
||
fixture *db.Fixture,
|
||
currentSchedule *db.FixtureSchedule,
|
||
history []*db.FixtureSchedule,
|
||
canSchedule bool,
|
||
userTeamID int,
|
||
) {
|
||
{{
|
||
permCache := contexts.Permissions(ctx)
|
||
canManage := permCache.HasPermission(permissions.FixturesManage)
|
||
}}
|
||
@fixtureScheduleTab(fixture, currentSchedule, history, canSchedule, canManage, userTeamID)
|
||
}
|
||
|
||
// ==================== Overview Tab ====================
|
||
templ fixtureOverviewTab(
|
||
fixture *db.Fixture,
|
||
currentSchedule *db.FixtureSchedule,
|
||
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 -->
|
||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<div class="lg:col-span-2">
|
||
if result != nil {
|
||
@fixtureResultDisplay(fixture, result)
|
||
} else if canManage {
|
||
@fixtureUploadPrompt(fixture)
|
||
} else {
|
||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||
<p class="text-subtext1 text-sm">No result has been uploaded for this fixture yet.</p>
|
||
</div>
|
||
}
|
||
</div>
|
||
<div>
|
||
@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, fixture.Season, fixture.League)
|
||
@fixtureTeamSection(fixture.AwayTeam, rosters["away"], "away", result, fixture.Season, fixture.League)
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ fixtureScheduleSummary(fixture *db.Fixture, schedule *db.FixtureSchedule, result *db.FixtureResult) {
|
||
{{
|
||
isPlayed := result != nil && result.Finalized
|
||
}}
|
||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden h-full">
|
||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||
<h2 class="text-lg font-bold text-text">Schedule</h2>
|
||
</div>
|
||
<div class="p-4 flex flex-col justify-center h-[calc(100%-3rem)]">
|
||
if schedule == nil {
|
||
<div class="text-center">
|
||
<p class="text-subtext1 text-sm">No time scheduled</p>
|
||
</div>
|
||
} else if schedule.Status == db.ScheduleStatusAccepted && schedule.ScheduledTime != nil {
|
||
<div class="text-center space-y-2">
|
||
if isPlayed {
|
||
<span class="px-2 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium">
|
||
Played
|
||
</span>
|
||
} else {
|
||
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||
Confirmed
|
||
</span>
|
||
}
|
||
<p class="text-text font-medium">
|
||
@localtime(schedule.ScheduledTime, "date")
|
||
</p>
|
||
<p class="text-text text-lg font-bold">
|
||
@localtime(schedule.ScheduledTime, "time")
|
||
</p>
|
||
</div>
|
||
} else if schedule.Status == db.ScheduleStatusPending && schedule.ScheduledTime != nil {
|
||
<div class="text-center space-y-2">
|
||
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||
Proposed
|
||
</span>
|
||
<p class="text-text font-medium">
|
||
@localtime(schedule.ScheduledTime, "date")
|
||
</p>
|
||
<p class="text-text text-lg font-bold">
|
||
@localtime(schedule.ScheduledTime, "time")
|
||
</p>
|
||
<p class="text-subtext1 text-xs">Awaiting confirmation</p>
|
||
</div>
|
||
} else if schedule.Status == db.ScheduleStatusCancelled {
|
||
<div class="text-center space-y-2">
|
||
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||
Forfeit
|
||
</span>
|
||
if schedule.RescheduleReason != nil {
|
||
<p class="text-subtext1 text-xs">{ *schedule.RescheduleReason }</p>
|
||
}
|
||
</div>
|
||
} else {
|
||
<div class="text-center">
|
||
<p class="text-subtext1 text-sm">No time confirmed</p>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ fixtureResultDisplay(fixture *db.Fixture, result *db.FixtureResult) {
|
||
{{
|
||
isOT := strings.EqualFold(result.EndReason, "Overtime")
|
||
homeWon := result.Winner == "home"
|
||
awayWon := result.Winner == "away"
|
||
isForfeit := result.IsForfeit
|
||
isMutualForfeit := isForfeit && result.ForfeitType != nil && *result.ForfeitType == "mutual"
|
||
isOutrightForfeit := isForfeit && result.ForfeitType != nil && *result.ForfeitType == "outright"
|
||
_ = isMutualForfeit
|
||
forfeitTeamName := ""
|
||
if isOutrightForfeit && result.ForfeitTeam != nil {
|
||
if *result.ForfeitTeam == "home" {
|
||
forfeitTeamName = fixture.HomeTeam.Name
|
||
} else {
|
||
forfeitTeamName = fixture.AwayTeam.Name
|
||
}
|
||
}
|
||
}}
|
||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||
<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">Match Result</h2>
|
||
<div class="flex items-center gap-2">
|
||
if isForfeit {
|
||
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||
Forfeited
|
||
</span>
|
||
}
|
||
if result.Finalized {
|
||
<span class="px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium">
|
||
Finalized
|
||
</span>
|
||
} else {
|
||
<span class="px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||
Pending Review
|
||
</span>
|
||
}
|
||
if !result.Finalized && result.TamperingDetected {
|
||
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||
Inconsistent Data
|
||
</span>
|
||
}
|
||
if result.Finalized && result.TamperingDetected && result.TamperingReason != nil {
|
||
<span class="relative group">
|
||
<span class="text-yellow text-lg cursor-help">⚠</span>
|
||
<span
|
||
class="absolute right-0 top-full mt-1 w-72 bg-crust border border-surface1 rounded-lg
|
||
p-3 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||
>
|
||
<span class="text-yellow font-semibold block mb-1">Inconsistent Data</span>
|
||
{ *result.TamperingReason }
|
||
</span>
|
||
</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
<div class="p-6">
|
||
if !result.Finalized && result.TamperingDetected && result.TamperingReason != nil {
|
||
<div class="bg-red/10 border border-red/30 rounded-lg p-4 mb-4">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="text-red font-medium text-sm">Warning: Inconsistent Data</span>
|
||
</div>
|
||
<p class="text-red/80 text-xs">{ *result.TamperingReason }</p>
|
||
</div>
|
||
}
|
||
if isForfeit {
|
||
<!-- Forfeit Display -->
|
||
<div class="flex flex-col items-center py-4 space-y-4">
|
||
if isMutualForfeit {
|
||
<div class="flex items-center justify-center gap-6">
|
||
<div class="flex items-center gap-3">
|
||
if fixture.HomeTeam.Color != "" {
|
||
<span
|
||
class="px-2.5 py-1 rounded text-sm font-bold"
|
||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
|
||
>
|
||
{ fixture.HomeTeam.ShortName }
|
||
</span>
|
||
} else {
|
||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||
{ fixture.HomeTeam.ShortName }
|
||
</span>
|
||
}
|
||
</div>
|
||
<div class="flex flex-col items-center">
|
||
<span class="px-3 py-1.5 bg-peach/20 text-peach rounded-lg text-sm font-bold">
|
||
MUTUAL FORFEIT
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
if fixture.AwayTeam.Color != "" {
|
||
<span
|
||
class="px-2.5 py-1 rounded text-sm font-bold"
|
||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
|
||
>
|
||
{ fixture.AwayTeam.ShortName }
|
||
</span>
|
||
} else {
|
||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||
{ fixture.AwayTeam.ShortName }
|
||
</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
<p class="text-sm text-subtext0">Both teams receive an overtime loss</p>
|
||
} else if isOutrightForfeit {
|
||
<div class="flex items-center justify-center gap-6">
|
||
<div class="flex items-center gap-3">
|
||
if homeWon {
|
||
<span class="text-2xl">🏆</span>
|
||
}
|
||
if fixture.HomeTeam.Color != "" {
|
||
<span
|
||
class="px-2.5 py-1 rounded text-sm font-bold"
|
||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
|
||
>
|
||
{ fixture.HomeTeam.ShortName }
|
||
</span>
|
||
} else {
|
||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||
{ fixture.HomeTeam.ShortName }
|
||
</span>
|
||
}
|
||
</div>
|
||
<div class="flex flex-col items-center">
|
||
<span class="px-3 py-1.5 bg-red/20 text-red rounded-lg text-sm font-bold">
|
||
FORFEIT
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
if fixture.AwayTeam.Color != "" {
|
||
<span
|
||
class="px-2.5 py-1 rounded text-sm font-bold"
|
||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
|
||
>
|
||
{ fixture.AwayTeam.ShortName }
|
||
</span>
|
||
} else {
|
||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||
{ fixture.AwayTeam.ShortName }
|
||
</span>
|
||
}
|
||
if awayWon {
|
||
<span class="text-2xl">🏆</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
<p class="text-sm text-subtext0">
|
||
{ forfeitTeamName } forfeited the match
|
||
</p>
|
||
}
|
||
if result.ForfeitReason != nil && *result.ForfeitReason != "" {
|
||
<div class="bg-surface0 border border-surface1 rounded-lg p-3 max-w-md w-full">
|
||
<p class="text-xs text-subtext1 font-medium mb-1">Reason</p>
|
||
<p class="text-sm text-subtext0">{ *result.ForfeitReason }</p>
|
||
</div>
|
||
}
|
||
</div>
|
||
} else {
|
||
<!-- Normal Score Display -->
|
||
<div class="flex items-center justify-center gap-6 py-4">
|
||
<div class="flex items-center gap-3">
|
||
if homeWon {
|
||
<span class="text-2xl">🏆</span>
|
||
}
|
||
if fixture.HomeTeam.Color != "" {
|
||
<span
|
||
class="px-2.5 py-1 rounded text-sm font-bold"
|
||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||
fixture.HomeTeam.Color, fixture.HomeTeam.Color, fixture.HomeTeam.Color) }
|
||
>
|
||
{ fixture.HomeTeam.ShortName }
|
||
</span>
|
||
} else {
|
||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||
{ fixture.HomeTeam.ShortName }
|
||
</span>
|
||
}
|
||
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.HomeScore) }</span>
|
||
</div>
|
||
<div class="flex flex-col items-center">
|
||
<span class="text-4xl text-subtext0 font-light leading-none">–</span>
|
||
if isOT {
|
||
<span class="px-1.5 py-0.5 bg-peach/20 text-peach rounded text-xs font-semibold mt-1">
|
||
OT
|
||
</span>
|
||
}
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-4xl font-bold text-text">{ fmt.Sprint(result.AwayScore) }</span>
|
||
if fixture.AwayTeam.Color != "" {
|
||
<span
|
||
class="px-2.5 py-1 rounded text-sm font-bold"
|
||
style={ fmt.Sprintf("background-color: %s22; color: %s; border: 1px solid %s44",
|
||
fixture.AwayTeam.Color, fixture.AwayTeam.Color, fixture.AwayTeam.Color) }
|
||
>
|
||
{ fixture.AwayTeam.ShortName }
|
||
</span>
|
||
} else {
|
||
<span class="px-2.5 py-1 bg-surface1 text-text rounded text-sm font-bold">
|
||
{ fixture.AwayTeam.ShortName }
|
||
</span>
|
||
}
|
||
if awayWon {
|
||
<span class="text-2xl">🏆</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ fixtureUploadPrompt(fixture *db.Fixture) {
|
||
<div
|
||
x-data="{
|
||
open: false,
|
||
forfeitType: 'outright',
|
||
forfeitTeam: '',
|
||
forfeitReason: '',
|
||
}"
|
||
>
|
||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||
<div class="text-4xl mb-3">📋</div>
|
||
<p class="text-lg text-text font-medium mb-2">No Result Uploaded</p>
|
||
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the result of this fixture.</p>
|
||
<div class="flex items-center justify-center gap-3">
|
||
<a
|
||
href={ templ.SafeURL(fmt.Sprintf("/fixtures/%d/results/upload", fixture.ID)) }
|
||
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||
font-medium transition hover:cursor-pointer"
|
||
>
|
||
Upload Match Logs
|
||
</a>
|
||
<button
|
||
type="button"
|
||
@click="open = true"
|
||
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||
font-medium transition hover:cursor-pointer"
|
||
>
|
||
Forfeit Match
|
||
</button>
|
||
</div>
|
||
</div>
|
||
@forfeitModal(fixture)
|
||
</div>
|
||
}
|
||
|
||
templ forfeitModal(fixture *db.Fixture) {
|
||
<div
|
||
x-show="open"
|
||
x-cloak
|
||
class="fixed inset-0 z-50 overflow-y-auto"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<!-- Background overlay -->
|
||
<div
|
||
x-show="open"
|
||
x-transition:enter="ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="ease-in duration-200"
|
||
x-transition:leave-start="opacity-100"
|
||
x-transition:leave-end="opacity-0"
|
||
class="fixed inset-0 bg-base/75 transition-opacity"
|
||
@click="open = false"
|
||
></div>
|
||
<!-- Modal panel -->
|
||
<div class="flex min-h-full items-center justify-center p-4">
|
||
<div
|
||
x-show="open"
|
||
x-transition:enter="ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||
x-transition:leave="ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||
class="relative transform overflow-hidden rounded-lg bg-mantle border-2 border-surface1 shadow-xl transition-all sm:w-full sm:max-w-lg"
|
||
@click.stop
|
||
>
|
||
<form
|
||
hx-post={ fmt.Sprintf("/fixtures/%d/forfeit", fixture.ID) }
|
||
hx-swap="none"
|
||
>
|
||
<div class="bg-mantle px-4 pb-4 pt-5 sm:p-6">
|
||
<div class="sm:flex sm:items-start">
|
||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red/10 sm:mx-0 sm:h-10 sm:w-10">
|
||
<svg class="h-6 w-6 text-red" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"></path>
|
||
</svg>
|
||
</div>
|
||
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left w-full">
|
||
<h3 class="text-lg font-semibold leading-6 text-text">Forfeit Match</h3>
|
||
<div class="mt-2">
|
||
<p class="text-sm text-subtext0 mb-4">
|
||
This will record a forfeit result. This action is immediate and cannot be undone.
|
||
</p>
|
||
<!-- Forfeit Type Selection -->
|
||
<div class="space-y-3">
|
||
<label class="text-sm font-medium text-text">Forfeit Type</label>
|
||
<div class="space-y-2">
|
||
<label class="flex items-center gap-3 p-3 bg-surface0 border border-surface1 rounded-lg hover:bg-surface1 transition hover:cursor-pointer"
|
||
:class="forfeitType === 'outright' && 'border-red/50 bg-red/5'"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="forfeit_type"
|
||
value="outright"
|
||
x-model="forfeitType"
|
||
class="text-red focus:ring-red hover:cursor-pointer"
|
||
/>
|
||
<div>
|
||
<span class="text-sm font-medium text-text">Outright Forfeit</span>
|
||
<p class="text-xs text-subtext0">One team forfeits. They receive a loss, the opponent receives a win.</p>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-center gap-3 p-3 bg-surface0 border border-surface1 rounded-lg hover:bg-surface1 transition hover:cursor-pointer"
|
||
:class="forfeitType === 'mutual' && 'border-peach/50 bg-peach/5'"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="forfeit_type"
|
||
value="mutual"
|
||
x-model="forfeitType"
|
||
class="text-peach focus:ring-peach hover:cursor-pointer"
|
||
/>
|
||
<div>
|
||
<span class="text-sm font-medium text-text">Mutual Forfeit</span>
|
||
<p class="text-xs text-subtext0">Both teams forfeit. Each receives an overtime loss.</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<!-- Team Selection (outright only) -->
|
||
<div x-show="forfeitType === 'outright'" x-cloak class="mt-4 space-y-2">
|
||
<label class="text-sm font-medium text-text">Which team is forfeiting?</label>
|
||
<select
|
||
name="forfeit_team"
|
||
x-model="forfeitTeam"
|
||
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text focus:border-red focus:outline-none hover:cursor-pointer"
|
||
>
|
||
<option value="">Select a team...</option>
|
||
<option value="home">{ fixture.HomeTeam.Name } (Home)</option>
|
||
<option value="away">{ fixture.AwayTeam.Name } (Away)</option>
|
||
</select>
|
||
</div>
|
||
<!-- Reason -->
|
||
<div class="mt-4 space-y-2">
|
||
<label class="text-sm font-medium text-text">Reason (optional)</label>
|
||
<textarea
|
||
name="forfeit_reason"
|
||
x-model="forfeitReason"
|
||
placeholder="Provide a reason for the forfeit..."
|
||
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
|
||
focus:border-blue focus:outline-none resize-none"
|
||
rows="3"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bg-surface0 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
||
<button
|
||
type="submit"
|
||
class="inline-flex w-full justify-center rounded-lg bg-red px-4 py-2 text-sm font-semibold text-mantle shadow-sm hover:bg-red/75 hover:cursor-pointer transition sm:w-auto"
|
||
:disabled="forfeitType === 'outright' && forfeitTeam === ''"
|
||
:class="forfeitType === 'outright' && forfeitTeam === '' && 'opacity-50 cursor-not-allowed'"
|
||
>
|
||
Confirm Forfeit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="open = false"
|
||
class="mt-3 inline-flex w-full justify-center rounded-lg bg-surface1 px-4 py-2 text-sm font-semibold text-text shadow-sm hover:bg-surface2 hover:cursor-pointer transition sm:mt-0 sm:w-auto"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ fixtureTeamSection(team *db.Team, players []*db.PlayerWithPlayStatus, side string, result *db.FixtureResult, season *db.Season, league *db.League) {
|
||
{{
|
||
// Separate playing and bench players
|
||
var playing []*db.PlayerWithPlayStatus
|
||
var bench []*db.PlayerWithPlayStatus
|
||
for _, p := range players {
|
||
if result == nil || p.Played {
|
||
playing = append(playing, p)
|
||
} else {
|
||
bench = append(bench, p)
|
||
}
|
||
}
|
||
showStats := result != nil && result.Finalized
|
||
if showStats {
|
||
// Sort playing players by score descending
|
||
sort.Slice(playing, func(i, j int) bool {
|
||
si, sj := 0, 0
|
||
if playing[i].Stats != nil && playing[i].Stats.Score != nil {
|
||
si = *playing[i].Stats.Score
|
||
}
|
||
if playing[j].Stats != nil && playing[j].Stats.Score != nil {
|
||
sj = *playing[j].Stats.Score
|
||
}
|
||
return si > sj
|
||
})
|
||
} else {
|
||
// Sort with managers first
|
||
sort.SliceStable(playing, func(i, j int) bool {
|
||
return playing[i].IsManager && !playing[j].IsManager
|
||
})
|
||
sort.SliceStable(bench, func(i, j int) bool {
|
||
return bench[i].IsManager && !bench[j].IsManager
|
||
})
|
||
}
|
||
}}
|
||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||
<h3 class="text-md font-bold">
|
||
@links.TeamNameLinkInSeason(team, season, league)
|
||
</h3>
|
||
if team.Color != "" {
|
||
<span
|
||
class="w-4 h-4 rounded-full border border-surface1"
|
||
style={ fmt.Sprintf("background-color: %s", team.Color) }
|
||
></span>
|
||
}
|
||
</div>
|
||
if len(players) == 0 {
|
||
<div class="p-4">
|
||
<p class="text-subtext1 text-sm text-center py-2">No players on roster.</p>
|
||
</div>
|
||
} else if showStats {
|
||
<!-- Stats table view for finalized results -->
|
||
if len(playing) > 0 {
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full">
|
||
<thead class="bg-surface0 border-b border-surface1">
|
||
<tr>
|
||
<th class="px-3 py-2 text-left text-xs font-semibold text-text">Player</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Periods Played">PP</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Score">SC</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Goals">G</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Assists">A</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Saves">SV</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Shots">SH</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Blocks">BL</th>
|
||
<th class="px-2 py-2 text-center text-xs font-semibold text-text" title="Passes">PA</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-surface1">
|
||
for _, p := range playing {
|
||
<tr class="hover:bg-surface0 transition-colors">
|
||
<td class="px-3 py-2 text-sm">
|
||
<span class="flex items-center gap-1.5">
|
||
@links.PlayerLink(p.Player)
|
||
if p.IsManager {
|
||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||
★
|
||
</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 {
|
||
<td class="px-2 py-2 text-center text-sm text-subtext0">{ intPtrStr(p.Stats.PeriodsPlayed) }</td>
|
||
<td class="px-2 py-2 text-center text-sm font-medium text-text">{ intPtrStr(p.Stats.Score) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Goals) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Assists) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Saves) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Shots) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Blocks) }</td>
|
||
<td class="px-2 py-2 text-center text-sm text-text">{ intPtrStr(p.Stats.Passes) }</td>
|
||
} else {
|
||
<td colspan="8" class="px-2 py-2 text-center text-xs text-subtext1">—</td>
|
||
}
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
if len(bench) > 0 {
|
||
<div class="border-t border-surface1 px-4 py-3">
|
||
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">Bench</p>
|
||
<div class="space-y-1">
|
||
for _, p := range bench {
|
||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||
<span class="text-sm text-subtext1">
|
||
@links.PlayerLink(p.Player)
|
||
</span>
|
||
if p.IsManager {
|
||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||
★ Manager
|
||
</span>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
} else {
|
||
<!-- Simple list view (no result or pending review) -->
|
||
<div class="p-4">
|
||
if len(playing) > 0 {
|
||
if result != nil {
|
||
<p class="text-xs text-subtext0 font-semibold uppercase mb-2">Playing</p>
|
||
}
|
||
<div class="space-y-1">
|
||
for _, p := range playing {
|
||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface0 transition">
|
||
<span class="text-sm">
|
||
@links.PlayerLink(p.Player)
|
||
</span>
|
||
if p.IsManager {
|
||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||
★ 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>
|
||
}
|
||
if result != nil && len(bench) > 0 {
|
||
<p class="text-xs text-subtext0 font-semibold uppercase mt-4 mb-2">Bench</p>
|
||
<div class="space-y-1">
|
||
for _, p := range bench {
|
||
<div class="flex items-center gap-2 px-2 py-1.5 rounded">
|
||
<span class="text-sm text-subtext1">
|
||
@links.PlayerLink(p.Player)
|
||
</span>
|
||
if p.IsManager {
|
||
<span class="px-1.5 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium">
|
||
★ Manager
|
||
</span>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
</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">
|
||
@links.PlayerLink(n.Player)
|
||
</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">
|
||
@links.PlayerLink(n.Player)
|
||
</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,
|
||
currentSchedule *db.FixtureSchedule,
|
||
history []*db.FixtureSchedule,
|
||
canSchedule bool,
|
||
canManage bool,
|
||
userTeamID int,
|
||
) {
|
||
<div class="space-y-6">
|
||
@fixtureScheduleStatus(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||
@fixtureScheduleActions(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
||
@fixtureScheduleHistory(fixture, history)
|
||
</div>
|
||
}
|
||
|
||
templ fixtureScheduleStatus(
|
||
fixture *db.Fixture,
|
||
current *db.FixtureSchedule,
|
||
canSchedule bool,
|
||
canManage bool,
|
||
userTeamID int,
|
||
) {
|
||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||
<h2 class="text-lg font-bold text-text">Schedule Status</h2>
|
||
</div>
|
||
<div class="p-6">
|
||
if current == nil {
|
||
<!-- No schedule yet -->
|
||
<div class="text-center py-4">
|
||
<div class="text-4xl mb-3">📅</div>
|
||
<p class="text-lg text-text font-medium">No time scheduled</p>
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
if canSchedule {
|
||
Use the form to propose a time for this fixture.
|
||
} else {
|
||
A team manager needs to propose a time for this fixture.
|
||
}
|
||
</p>
|
||
</div>
|
||
} else if current.Status == db.ScheduleStatusPending && current.ScheduledTime != nil {
|
||
<!-- Pending proposal with time -->
|
||
<div class="text-center py-4">
|
||
<div class="text-4xl mb-3">⏳</div>
|
||
<p class="text-lg text-text font-medium">
|
||
Proposed:
|
||
@localtime(current.ScheduledTime, "datetime")
|
||
</p>
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
Proposed by
|
||
<span class="text-text font-medium">{ current.ProposedBy.Name }</span>
|
||
— awaiting response from the other team
|
||
</p>
|
||
if canSchedule && userTeamID != current.ProposedByTeamID {
|
||
<div class="flex justify-center gap-3 mt-4">
|
||
<form
|
||
hx-post={ fmt.Sprintf("/fixtures/%d/schedule/%d/accept", fixture.ID, current.ID) }
|
||
hx-swap="none"
|
||
>
|
||
<button
|
||
type="submit"
|
||
class="px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||
font-medium transition hover:cursor-pointer"
|
||
>
|
||
Accept
|
||
</button>
|
||
</form>
|
||
<form
|
||
hx-post={ fmt.Sprintf("/fixtures/%d/schedule/%d/reject", fixture.ID, current.ID) }
|
||
hx-swap="none"
|
||
>
|
||
<button
|
||
type="submit"
|
||
class="px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||
font-medium transition hover:cursor-pointer"
|
||
>
|
||
Reject
|
||
</button>
|
||
</form>
|
||
</div>
|
||
}
|
||
if canSchedule && userTeamID == current.ProposedByTeamID {
|
||
<div class="flex justify-center mt-4">
|
||
<button
|
||
type="button"
|
||
onclick={ templ.JSUnsafeFuncCall("window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Withdraw Proposal', message: 'Are you sure you want to withdraw your proposed time?', action: () => htmx.ajax('POST', '/fixtures/" + fmt.Sprint(fixture.ID) + "/schedule/" + fmt.Sprint(current.ID) + "/withdraw', { swap: 'none' }) } }))") }
|
||
class="px-4 py-2 bg-surface1 hover:bg-surface2 text-text rounded-lg
|
||
font-medium transition hover:cursor-pointer"
|
||
>
|
||
Withdraw Proposal
|
||
</button>
|
||
</div>
|
||
}
|
||
</div>
|
||
} else if current.Status == db.ScheduleStatusAccepted {
|
||
<!-- Accepted / Confirmed -->
|
||
<div class="text-center py-4">
|
||
<div class="text-4xl mb-3">✅</div>
|
||
<p class="text-lg text-green font-medium">
|
||
Confirmed:
|
||
@localtime(current.ScheduledTime, "datetime")
|
||
</p>
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
Both teams have agreed on this time.
|
||
</p>
|
||
</div>
|
||
} else if current.Status == db.ScheduleStatusRejected {
|
||
<!-- Rejected -->
|
||
<div class="text-center py-4">
|
||
<div class="text-4xl mb-3">❌</div>
|
||
<p class="text-lg text-red font-medium">Proposal Rejected</p>
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
The proposed time was rejected. A new time needs to be proposed.
|
||
</p>
|
||
</div>
|
||
} else if current.Status == db.ScheduleStatusCancelled {
|
||
<!-- Cancelled / Forfeit -->
|
||
<div class="text-center py-4">
|
||
<div class="text-4xl mb-3">🚫</div>
|
||
<p class="text-lg text-red font-medium">Fixture Forfeited</p>
|
||
if current.RescheduleReason != nil {
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
{ *current.RescheduleReason }
|
||
</p>
|
||
}
|
||
</div>
|
||
} else if current.Status == db.ScheduleStatusRescheduled {
|
||
<!-- Rescheduled (terminal - new proposal should follow) -->
|
||
<div class="text-center py-4">
|
||
<div class="text-4xl mb-3">🔄</div>
|
||
<p class="text-lg text-yellow font-medium">Rescheduled</p>
|
||
if current.RescheduleReason != nil {
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
Reason: { *current.RescheduleReason }
|
||
</p>
|
||
}
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
A new time needs to be proposed.
|
||
</p>
|
||
</div>
|
||
} else if current.Status == db.ScheduleStatusPostponed {
|
||
<!-- Postponed (terminal - new proposal should follow) -->
|
||
<div class="text-center py-4">
|
||
<div class="text-4xl mb-3">⏸️</div>
|
||
<p class="text-lg text-peach font-medium">Postponed</p>
|
||
if current.RescheduleReason != nil {
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
Reason: { *current.RescheduleReason }
|
||
</p>
|
||
}
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
A new time needs to be proposed.
|
||
</p>
|
||
</div>
|
||
} else if current.Status == db.ScheduleStatusWithdrawn {
|
||
<!-- Withdrawn -->
|
||
<div class="text-center py-4">
|
||
<div class="text-4xl mb-3">↩️</div>
|
||
<p class="text-lg text-subtext0 font-medium">Proposal Withdrawn</p>
|
||
<p class="text-sm text-subtext1 mt-1">
|
||
The proposed time was withdrawn. A new time needs to be proposed.
|
||
</p>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ fixtureScheduleActions(
|
||
fixture *db.Fixture,
|
||
current *db.FixtureSchedule,
|
||
canSchedule bool,
|
||
canManage bool,
|
||
userTeamID int,
|
||
) {
|
||
{{
|
||
// Determine what actions are available
|
||
showPropose := false
|
||
showReschedule := false
|
||
showPostpone := false
|
||
showCancel := false
|
||
|
||
if canSchedule {
|
||
if current == nil {
|
||
showPropose = true
|
||
} else if current.Status == db.ScheduleStatusRejected {
|
||
showPropose = true
|
||
} else if current.Status == db.ScheduleStatusRescheduled {
|
||
showPropose = true
|
||
} else if current.Status == db.ScheduleStatusPostponed {
|
||
showPropose = true
|
||
} else if current.Status == db.ScheduleStatusWithdrawn {
|
||
showPropose = true
|
||
} else if current.Status == db.ScheduleStatusAccepted {
|
||
showReschedule = true
|
||
showPostpone = true
|
||
}
|
||
}
|
||
if canManage && current != nil && !current.Status.IsTerminal() {
|
||
showCancel = true
|
||
}
|
||
}}
|
||
if showPropose || showReschedule || showPostpone || showCancel {
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
<!-- Propose Time -->
|
||
if showPropose {
|
||
<div class="bg-mantle border border-surface1 rounded-lg">
|
||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||
<h3 class="text-md font-bold text-text">Propose Time</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<form
|
||
hx-post={ fmt.Sprintf("/fixtures/%d/schedule", fixture.ID) }
|
||
hx-swap="none"
|
||
class="space-y-4"
|
||
>
|
||
<div>
|
||
<label class="block text-sm font-medium text-subtext0 mb-1">
|
||
Date & Time
|
||
<span class="relative group inline-block ml-1">
|
||
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
||
<span
|
||
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
||
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||
>
|
||
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<input
|
||
type="datetime-local"
|
||
name="scheduled_time"
|
||
required
|
||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||
focus:border-blue focus:outline-none"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
class="w-full px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||
font-medium transition hover:cursor-pointer"
|
||
>
|
||
Propose Time
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
}
|
||
<!-- Reschedule -->
|
||
if showReschedule {
|
||
<div class="bg-mantle border border-surface1 rounded-lg">
|
||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||
<h3 class="text-md font-bold text-text">Reschedule</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<form
|
||
hx-post={ fmt.Sprintf("/fixtures/%d/schedule/reschedule", fixture.ID) }
|
||
hx-swap="none"
|
||
class="space-y-4"
|
||
>
|
||
<div>
|
||
<label class="block text-sm font-medium text-subtext0 mb-1">
|
||
New Date & Time
|
||
<span class="relative group inline-block ml-1">
|
||
<span class="px-1.5 py-0.5 bg-blue/20 text-blue rounded text-xs font-medium cursor-help">AEST/AEDT</span>
|
||
<span
|
||
class="absolute left-0 top-full mt-1 w-56 bg-crust border border-surface1 rounded-lg
|
||
p-2 text-xs text-subtext0 shadow-lg opacity-0 invisible
|
||
group-hover:opacity-100 group-hover:visible transition-all z-50"
|
||
>
|
||
Enter the time in Australian Eastern time (AEST/AEDT). It will be displayed in each user's local timezone.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
<input
|
||
type="datetime-local"
|
||
name="scheduled_time"
|
||
required
|
||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||
focus:border-blue focus:outline-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||
@rescheduleReasonSelect(fixture)
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
class="w-full px-4 py-2 bg-yellow hover:bg-yellow/80 text-mantle rounded-lg
|
||
font-medium transition hover:cursor-pointer"
|
||
>
|
||
Reschedule
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
}
|
||
<!-- Postpone -->
|
||
if showPostpone {
|
||
<div class="bg-mantle border border-surface1 rounded-lg">
|
||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 rounded-t-lg">
|
||
<h3 class="text-md font-bold text-text">Postpone</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-subtext0 mb-1">Reason</label>
|
||
@rescheduleReasonSelect(fixture)
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Postpone Fixture', message: 'Are you sure you want to postpone this fixture? The current schedule will be cleared.', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/postpone', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", fixture.ID)) }
|
||
class="w-full px-4 py-2 bg-peach hover:bg-peach/80 text-mantle rounded-lg
|
||
font-medium transition hover:cursor-pointer"
|
||
>
|
||
Postpone Fixture
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
<!-- Declare Forfeit (moderator only) -->
|
||
if showCancel {
|
||
<div class="bg-mantle border border-red/30 rounded-lg">
|
||
<div class="bg-red/10 border-b border-red/30 px-4 py-3 rounded-t-lg">
|
||
<h3 class="text-md font-bold text-red">Declare Forfeit</h3>
|
||
</div>
|
||
<div class="p-4">
|
||
<p class="text-xs text-red/80 mb-3 font-medium">
|
||
This action is irreversible. Declaring a forfeit will permanently cancel the fixture schedule.
|
||
</p>
|
||
<div class="space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-subtext0 mb-1">Forfeiting Team</label>
|
||
@forfeitReasonSelect(fixture)
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onclick={ templ.JSUnsafeFuncCall(fmt.Sprintf("if (!this.parentElement.querySelector('select').value) { return; } window.dispatchEvent(new CustomEvent('confirm-action', { detail: { title: 'Declare Forfeit', message: 'This action is IRREVERSIBLE. The fixture schedule will be permanently cancelled. Are you sure?', action: () => htmx.ajax('POST', '/fixtures/%d/schedule/cancel', { swap: 'none', values: { reschedule_reason: this.parentElement.querySelector('select').value } }) } }))", fixture.ID)) }
|
||
class="w-full px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||
font-medium transition hover:cursor-pointer"
|
||
>
|
||
Declare Forfeit
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
} else {
|
||
if !canSchedule && !canManage {
|
||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||
<p class="text-subtext1 text-sm">
|
||
Only team managers can manage fixture scheduling.
|
||
</p>
|
||
</div>
|
||
}
|
||
}
|
||
}
|
||
|
||
templ forfeitReasonSelect(fixture *db.Fixture) {
|
||
<select
|
||
name="reschedule_reason"
|
||
required
|
||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||
focus:border-blue focus:outline-none hover:cursor-pointer"
|
||
>
|
||
<option value="" disabled selected>Select forfeiting team</option>
|
||
<option value={ fmt.Sprintf("%s Forfeit", fixture.HomeTeam.Name) }>
|
||
{ fixture.HomeTeam.Name } Forfeit
|
||
</option>
|
||
<option value={ fmt.Sprintf("%s Forfeit", fixture.AwayTeam.Name) }>
|
||
{ fixture.AwayTeam.Name } Forfeit
|
||
</option>
|
||
</select>
|
||
}
|
||
|
||
templ rescheduleReasonSelect(fixture *db.Fixture) {
|
||
<select
|
||
name="reschedule_reason"
|
||
required
|
||
class="w-full px-3 py-2 bg-base border border-surface1 rounded-lg text-text
|
||
focus:border-blue focus:outline-none hover:cursor-pointer"
|
||
>
|
||
<option value="" disabled selected>Select a reason</option>
|
||
<option value="Mutually Agreed">Mutually Agreed</option>
|
||
<option value={ fmt.Sprintf("%s Unavailable", fixture.HomeTeam.Name) }>
|
||
{ fixture.HomeTeam.Name } Unavailable
|
||
</option>
|
||
<option value={ fmt.Sprintf("%s Unavailable", fixture.AwayTeam.Name) }>
|
||
{ fixture.AwayTeam.Name } Unavailable
|
||
</option>
|
||
<option value={ fmt.Sprintf("%s No-show", fixture.HomeTeam.Name) }>
|
||
{ fixture.HomeTeam.Name } No-show
|
||
</option>
|
||
<option value={ fmt.Sprintf("%s No-show", fixture.AwayTeam.Name) }>
|
||
{ fixture.AwayTeam.Name } No-show
|
||
</option>
|
||
</select>
|
||
}
|
||
|
||
templ fixtureScheduleHistory(fixture *db.Fixture, history []*db.FixtureSchedule) {
|
||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||
<div class="bg-surface0 border-b border-surface1 px-4 py-3">
|
||
<h2 class="text-lg font-bold text-text">Schedule History</h2>
|
||
</div>
|
||
<div class="p-4">
|
||
if len(history) == 0 {
|
||
<p class="text-subtext1 text-sm text-center py-4">No scheduling activity yet.</p>
|
||
} else {
|
||
<div class="space-y-3">
|
||
for i := len(history) - 1; i >= 0; i-- {
|
||
@scheduleHistoryItem(history[i], i == len(history)-1)
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ scheduleHistoryItem(schedule *db.FixtureSchedule, isCurrent bool) {
|
||
{{
|
||
statusColor := "text-subtext0"
|
||
statusBg := "bg-surface1"
|
||
statusLabel := string(schedule.Status)
|
||
switch schedule.Status {
|
||
case db.ScheduleStatusPending:
|
||
statusColor = "text-blue"
|
||
statusBg = "bg-blue/20"
|
||
statusLabel = "Pending"
|
||
case db.ScheduleStatusAccepted:
|
||
statusColor = "text-green"
|
||
statusBg = "bg-green/20"
|
||
statusLabel = "Accepted"
|
||
case db.ScheduleStatusRejected:
|
||
statusColor = "text-red"
|
||
statusBg = "bg-red/20"
|
||
statusLabel = "Rejected"
|
||
case db.ScheduleStatusRescheduled:
|
||
statusColor = "text-yellow"
|
||
statusBg = "bg-yellow/20"
|
||
statusLabel = "Rescheduled"
|
||
case db.ScheduleStatusPostponed:
|
||
statusColor = "text-peach"
|
||
statusBg = "bg-peach/20"
|
||
statusLabel = "Postponed"
|
||
case db.ScheduleStatusCancelled:
|
||
statusColor = "text-red"
|
||
statusBg = "bg-red/20"
|
||
statusLabel = "Cancelled"
|
||
case db.ScheduleStatusWithdrawn:
|
||
statusColor = "text-subtext0"
|
||
statusBg = "bg-surface1"
|
||
statusLabel = "Withdrawn"
|
||
}
|
||
}}
|
||
<div class={ "border rounded-lg p-3", templ.KV("border-surface1", !isCurrent), templ.KV("border-blue/30 bg-blue/5", isCurrent) }>
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2">
|
||
if isCurrent {
|
||
<span class="text-xs px-1.5 py-0.5 bg-blue/20 text-blue rounded font-medium">
|
||
CURRENT
|
||
</span>
|
||
}
|
||
<span class={ "text-xs px-2 py-0.5 rounded font-medium", statusBg, statusColor }>
|
||
{ statusLabel }
|
||
</span>
|
||
</div>
|
||
<span class="text-xs text-subtext1">
|
||
@localtimeUnix(schedule.CreatedAt, "histdate")
|
||
</span>
|
||
</div>
|
||
<div class="space-y-1">
|
||
<div class="flex items-center gap-2 text-sm">
|
||
<span class="text-subtext0">Proposed by:</span>
|
||
<span class="text-text font-medium">{ schedule.ProposedBy.Name }</span>
|
||
</div>
|
||
if schedule.ScheduledTime != nil {
|
||
<div class="flex items-center gap-2 text-sm">
|
||
<span class="text-subtext0">Time:</span>
|
||
<span class="text-text">
|
||
@localtime(schedule.ScheduledTime, "datetime")
|
||
</span>
|
||
</div>
|
||
} else {
|
||
<div class="flex items-center gap-2 text-sm">
|
||
<span class="text-subtext0">Time:</span>
|
||
<span class="text-subtext1 italic">No time set</span>
|
||
</div>
|
||
}
|
||
if schedule.AcceptedBy != nil {
|
||
<div class="flex items-center gap-2 text-sm">
|
||
<span class="text-subtext0">Accepted by:</span>
|
||
<span class="text-text font-medium">{ schedule.AcceptedBy.Name }</span>
|
||
</div>
|
||
}
|
||
if schedule.RescheduleReason != nil {
|
||
<div class="flex items-center gap-2 text-sm">
|
||
<span class="text-subtext0">Reason:</span>
|
||
<span class="text-text">{ *schedule.RescheduleReason }</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|