Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/Humans.Web/Models/ShiftViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,43 @@ public class ShiftSignupsViewModel
public string? DisplayName { get; set; }
}

// === Rota Partial View Models ===

/// <summary>
/// Model for the _RotaHeader partial — displays rota name, priority, period, visibility,
/// tags, description, and practical info. Used by both volunteer and admin views.
/// </summary>
public class RotaHeaderViewModel
{
public Rota Rota { get; set; } = null!;
public bool ShowPreferenceStar { get; set; }
}

/// <summary>
/// Model for the _BuildStrikeRotaTable partial — all-day shift table with collapsible
/// date ranges and range signup form. Used by the volunteer browse view.
/// </summary>
public class BuildStrikeRotaTableViewModel
{
public RotaShiftGroup RotaGroup { get; set; } = null!;
public EventSettings EventSettings { get; set; } = null!;
public HashSet<Guid> UserSignupShiftIds { get; set; } = [];
public bool ShowSignups { get; set; }
}

/// <summary>
/// Model for the _EventRotaTable partial — timed shift table with individual signup buttons.
/// Used by the volunteer browse view.
/// </summary>
public class EventRotaTableViewModel
{
public List<ShiftDisplayItem> Shifts { get; set; } = [];
public EventSettings EventSettings { get; set; } = null!;
public HashSet<Guid> UserSignupShiftIds { get; set; } = [];
public Dictionary<Guid, SignupStatus> UserSignupStatuses { get; set; } = new();
public bool ShowSignups { get; set; }
}

// === No-Show History ===

public class NoShowHistoryItem
Expand Down
207 changes: 207 additions & 0 deletions src/Humans.Web/Views/Shared/_BuildStrikeRotaTable.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
@model Humans.Web.Models.BuildStrikeRotaTableViewModel
@{
var rotaGroup = Model.RotaGroup;
var es = Model.EventSettings;
var allDayShifts = rotaGroup.Shifts.Where(s => s.Shift.IsAllDay).OrderBy(s => s.Shift.DayOffset).ToList();
var availableShifts = allDayShifts.Where(s => s.RemainingSlots > 0 && !Model.UserSignupShiftIds.Contains(s.Shift.Id)).ToList();
}
@if (availableShifts.Count > 0)
{
<form asp-controller="Shifts" asp-action="SignUpRange" method="post" class="row g-2 align-items-end mb-3">
@Html.AntiForgeryToken()
<input type="hidden" name="rotaId" value="@rotaGroup.Rota.Id" />
<div class="col-auto">
<label class="form-label form-label-sm">Start</label>
<select name="startDayOffset" class="form-select form-select-sm">
@foreach (var s in availableShifts)
{
<option value="@s.Shift.DayOffset">@es.GateOpeningDate.PlusDays(s.Shift.DayOffset).ToDisplayShiftDate()</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label form-label-sm">End</label>
<select name="endDayOffset" class="form-select form-select-sm">
@foreach (var s in availableShifts)
{
<option value="@s.Shift.DayOffset" selected="@(s == availableShifts.Last() ? "selected" : null)">@es.GateOpeningDate.PlusDays(s.Shift.DayOffset).ToDisplayShiftDate()</option>
}
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-success">Sign up for @(rotaGroup.Rota.Period == RotaPeriod.Build ? "set-up" : "strike") dates</button>
</div>
</form>
}
@{
var dateRanges = new List<List<Humans.Web.Models.ShiftDisplayItem>>();
List<Humans.Web.Models.ShiftDisplayItem>? currentRange = null;
foreach (var s in allDayShifts)
{
if (currentRange == null || s.Shift.DayOffset != currentRange.Last().Shift.DayOffset + 1)
{
currentRange = new List<Humans.Web.Models.ShiftDisplayItem> { s };
dateRanges.Add(currentRange);
}
else
{
currentRange.Add(s);
}
}
}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr><th>Date</th><th>Filled</th><th>Status</th>@if (Model.ShowSignups) {<th>@Localizer["Shifts_SignedUp"]</th>}</tr>
</thead>
<tbody>
@foreach (var range in dateRanges)
{
if (range.Count > 1)
{
var rangeKey = $"r{range.First().Shift.Id:N}";
var totalConfirmed = range.Sum(x => x.ConfirmedCount);
var totalMax = range.Sum(x => x.Shift.MaxVolunteers);
var anyUnderstaffed = range.Any(x => x.ConfirmedCount < x.Shift.MinVolunteers);
var daysNeedingHelp = range.Count(x => x.ConfirmedCount < x.Shift.MinVolunteers);
var allRangeFull = range.All(x => x.RemainingSlots <= 0);
var userSignedUpDays = range.Count(x => Model.UserSignupShiftIds.Contains(x.Shift.Id));
var rangeStart = es.GateOpeningDate.PlusDays(range.First().Shift.DayOffset);
var rangeEnd = es.GateOpeningDate.PlusDays(range.Last().Shift.DayOffset);
<tr class="@(allRangeFull ? "table-secondary" : "") shift-range-header" role="button" data-range="@rangeKey" style="cursor: pointer;">
<td>
<i class="fa-solid fa-chevron-right fa-xs me-1 range-icon"></i>
@rangeStart.ToDisplayShiftDate()–@rangeEnd.ToDisplayShiftDate()
<small class="text-muted">(@range.Count days)</small>
</td>
<td>
<span class="@(anyUnderstaffed ? "text-danger fw-bold" : "")">@totalConfirmed</span>
<small class="text-muted">/ @totalMax total</small>
</td>
<td>
@if (userSignedUpDays == range.Count)
{
<span class="badge bg-info">Signed up (all @range.Count days)</span>
}
else if (userSignedUpDays > 0)
{
<span class="badge bg-info">Signed up (@userSignedUpDays of @range.Count)</span>
}
else if (allRangeFull)
{
<span class="badge bg-secondary">Full</span>
}
else if (daysNeedingHelp > 0)
{
<span class="badge bg-warning text-dark">@daysNeedingHelp @(daysNeedingHelp == 1 ? "day needs" : "days need") help</span>
}
</td>
@if (Model.ShowSignups)
{
<td><small class="text-muted fst-italic">click to expand</small></td>
}
</tr>
@foreach (var item in range)
{
var isFull = item.RemainingSlots <= 0;
var understaffed = item.ConfirmedCount < item.Shift.MinVolunteers;
<tr class="d-none range-detail-@rangeKey @(isFull ? "table-secondary" : "")">
<td class="ps-4">@es.GateOpeningDate.PlusDays(item.Shift.DayOffset).ToDisplayShiftDate()</td>
<td>
<span class="@(understaffed ? "text-danger fw-bold" : "")">@item.ConfirmedCount</span>
<small class="text-muted">/ @item.Shift.MaxVolunteers</small>
</td>
<td>
@if (Model.UserSignupShiftIds.Contains(item.Shift.Id))
{
<span class="badge bg-info">Signed up</span>
}
else if (isFull)
{
<span class="badge bg-secondary">Full</span>
}
else if (understaffed)
{
<span class="badge bg-warning text-dark">Needs help</span>
}
</td>
@if (Model.ShowSignups)
{
<td>
<div class="d-flex flex-wrap gap-1">
@foreach (var signup in item.Signups)
{
<human-link user-id="@signup.UserId" display-name="@signup.DisplayName" mode="Avatar" size="26"
profile-picture-url="@signup.ProfilePictureUrl" show-popover="true"
title="@signup.DisplayName@(signup.Status == SignupStatus.Pending ? $" {Localizer["Shifts_Pending"]}" : "")"
class="@(signup.Status == SignupStatus.Pending ? "opacity-50" : "")"
style="@(signup.Status == SignupStatus.Pending ? "border: 1px dashed var(--h-muted-text); border-radius: 50%;" : "")" />
}
</div>
</td>
}
</tr>
@if (!string.IsNullOrEmpty(item.Shift.Description))
{
<tr class="d-none range-detail-@rangeKey @(isFull ? "table-secondary" : "")">
<td colspan="@(Model.ShowSignups ? 4 : 3)" class="text-muted small pt-0 pb-1 border-0">
@Html.SanitizedMarkdown(item.Shift.Description)
</td>
</tr>
}
}
}
else
{
var item = range[0];
var isFull = item.RemainingSlots <= 0;
var understaffed = item.ConfirmedCount < item.Shift.MinVolunteers;
<tr class="@(isFull ? "table-secondary" : "")">
<td>@es.GateOpeningDate.PlusDays(item.Shift.DayOffset).ToDisplayShiftDate()</td>
<td>
<span class="@(understaffed ? "text-danger fw-bold" : "")">@item.ConfirmedCount</span>
<small class="text-muted">/ @item.Shift.MaxVolunteers</small>
</td>
<td>
@if (Model.UserSignupShiftIds.Contains(item.Shift.Id))
{
<span class="badge bg-info">Signed up</span>
}
else if (isFull)
{
<span class="badge bg-secondary">Full</span>
}
else if (understaffed)
{
<span class="badge bg-warning text-dark">Needs help</span>
}
</td>
@if (Model.ShowSignups)
{
<td>
<div class="d-flex flex-wrap gap-1">
@foreach (var signup in item.Signups)
{
<human-link user-id="@signup.UserId" display-name="@signup.DisplayName" mode="Avatar" size="26"
profile-picture-url="@signup.ProfilePictureUrl" show-popover="true"
title="@signup.DisplayName@(signup.Status == SignupStatus.Pending ? $" {Localizer["Shifts_Pending"]}" : "")"
class="@(signup.Status == SignupStatus.Pending ? "opacity-50" : "")"
style="@(signup.Status == SignupStatus.Pending ? "border: 1px dashed var(--h-muted-text); border-radius: 50%;" : "")" />
}
</div>
</td>
}
</tr>
@if (!string.IsNullOrEmpty(item.Shift.Description))
{
<tr class="@(isFull ? "table-secondary" : "")">
<td colspan="@(Model.ShowSignups ? 4 : 3)" class="text-muted small pt-0 pb-1 border-0">
@Html.SanitizedMarkdown(item.Shift.Description)
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
113 changes: 113 additions & 0 deletions src/Humans.Web/Views/Shared/_EventRotaTable.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
@model Humans.Web.Models.EventRotaTableViewModel
@using NodaTime

@functions {
string GetPhase(Shift shift, EventSettings es)
{
return shift.GetShiftPeriod(es) switch
{
ShiftPeriod.Build => "Set-up",
ShiftPeriod.Event => "Event",
ShiftPeriod.Strike => "Strike",
_ => ""
};
}

string GetPhaseBadge(Shift shift, EventSettings es)
{
return shift.GetShiftPeriod(es) switch
{
ShiftPeriod.Build => "bg-info",
ShiftPeriod.Event => "bg-success",
ShiftPeriod.Strike => "bg-secondary",
_ => "bg-secondary"
};
}
}

@{
var es = Model.EventSettings;
var tz = DateTimeZoneProviders.Tzdb[es.TimeZoneId];
}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Shift</th>
<th>Phase</th>
<th>Date</th>
<th>Time</th>
<th>Duration</th>
<th>Filled</th>
@if (Model.ShowSignups)
{
<th>@Localizer["Shifts_SignedUp"]</th>
}
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Shifts)
{
var shift = item.Shift;
var isFull = item.RemainingSlots <= 0;
var understaffed = item.ConfirmedCount < shift.MinVolunteers;
<tr class="@(isFull ? "table-secondary" : "")">
<td>@(shift.IsAllDay ? "All day" : $"{shift.StartTime.ToDisplayTime()}–{shift.StartTime.PlusMinutes((int)shift.Duration.TotalMinutes).ToDisplayTime()}")</td>
<td><span class="badge @GetPhaseBadge(shift, es)">@GetPhase(shift, es)</span></td>
<td>@es.GateOpeningDate.PlusDays(shift.DayOffset).ToDisplayShiftDate()</td>
<td>@item.AbsoluteStart.ToDisplayTime(tz)</td>
<td>@(shift.IsAllDay ? "Full day" : $"{shift.Duration.TotalHours}h")</td>
<td>
<span class="@(understaffed ? "text-danger fw-bold" : "")">@item.ConfirmedCount</span>
<small class="text-muted">/ @shift.MinVolunteers–@shift.MaxVolunteers</small>
@if (understaffed)
{
<span class="badge bg-warning text-dark">Needs help</span>
}
</td>
@if (Model.ShowSignups)
{
<td class="small">
@for (var i = 0; i < item.Signups.Count; i++)
{
var signup = item.Signups[i];
if (i > 0) { <text>, </text> }
<human-link user-id="@signup.UserId" display-name="@signup.DisplayName" show-popover="true"
class="@(signup.Status == SignupStatus.Pending ? "text-muted fst-italic" : "")" />@if (signup.Status == SignupStatus.Pending)
{<small class="text-muted"> @Localizer["Shifts_Pending"]</small>}
}
</td>
}
<td>
@if (Model.UserSignupShiftIds.Contains(shift.Id))
{
var status = Model.UserSignupStatuses.GetValueOrDefault(shift.Id);
<span class="badge bg-info">@status</span>
}
else if (isFull)
{
<span class="badge bg-secondary">Full</span>
}
else
{
<form asp-controller="Shifts" asp-action="SignUp" method="post" class="d-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="shiftId" value="@shift.Id" />
<button type="submit" class="btn btn-sm btn-success">Sign Up</button>
</form>
}
</td>
</tr>
@if (!string.IsNullOrEmpty(shift.Description))
{
<tr class="@(isFull ? "table-secondary" : "")">
<td colspan="8" class="text-muted small pt-0 pb-1 border-0">
@Html.SanitizedMarkdown(shift.Description)
</td>
</tr>
}
}
</tbody>
</table>
</div>
Loading