544 lines
19 KiB
Plaintext
544 lines
19 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 "fmt"
|
|
import "time"
|
|
|
|
func formatScheduleTime(t *time.Time) string {
|
|
if t == nil {
|
|
return "No time set"
|
|
}
|
|
return t.Format("Mon 2 Jan 2006 at 3:04 PM")
|
|
}
|
|
|
|
func formatHistoryTime(unix int64) string {
|
|
return time.Unix(unix, 0).Format("2 Jan 2006 15:04")
|
|
}
|
|
|
|
templ FixtureDetailPage(
|
|
fixture *db.Fixture,
|
|
currentSchedule *db.FixtureSchedule,
|
|
history []*db.FixtureSchedule,
|
|
canSchedule bool,
|
|
userTeamID int,
|
|
) {
|
|
{{
|
|
permCache := contexts.Permissions(ctx)
|
|
canManage := permCache.HasPermission(permissions.FixturesManage)
|
|
backURL := fmt.Sprintf("/seasons/%s/leagues/%s/fixtures", fixture.Season.ShortName, fixture.League.ShortName)
|
|
}}
|
|
@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>
|
|
</div>
|
|
<!-- Schedule Status + Actions -->
|
|
<div class="space-y-6">
|
|
@fixtureScheduleStatus(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
|
@fixtureScheduleActions(fixture, currentSchedule, canSchedule, canManage, userTeamID)
|
|
@fixtureScheduleHistory(fixture, history)
|
|
</div>
|
|
</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: { formatScheduleTime(current.ScheduledTime) }
|
|
</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"
|
|
@click={ "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: { formatScheduleTime(current.ScheduledTime) }
|
|
</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</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</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"
|
|
@click={ fmt.Sprintf("if (!$el.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: $el.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"
|
|
@click={ fmt.Sprintf("if (!$el.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: $el.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">
|
|
{ formatHistoryTime(schedule.CreatedAt) }
|
|
</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">{ formatScheduleTime(schedule.ScheduledTime) }</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>
|
|
}
|