Skip to content
1 change: 0 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ stan/pint - WIP

### WORK IN PROGRESS
- Add better controls for adding/removing troopers on backend CS panel for events
- Manual selection event, similar to TT 1.0
- Download event roster to CSV (used my CS at troopers to help manage an event in person with physical paper)

## REPORTS
Expand Down
7 changes: 7 additions & 0 deletions tracker-app/app/Enums/EventGuestStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ enum EventGuestStatus: string
{
use HasEnumHelpers;

/**
* Guest is on stand-by and requires approval.
*/
case STAND_BY = 'standby';

/**
* Guest is confirmed to be going.
*/
Expand All @@ -33,6 +38,7 @@ public function icon(): string
{
return match ($this)
{
self::STAND_BY => 'fa-circle-pause',
self::GOING => 'fa-circle-play',
self::TENTATIVE => 'fa-circle-dot',
self::CANCELLED => 'fa-times-circle',
Expand All @@ -48,6 +54,7 @@ public function color(): string
{
return match ($this)
{
self::STAND_BY => 'text-warning',
self::GOING => 'text-success',
self::TENTATIVE => 'text-warning',
self::CANCELLED => 'text-danger',
Expand Down
5 changes: 5 additions & 0 deletions tracker-app/app/Enums/EventStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,9 @@ enum EventStatus: string
* The event has been locked.
*/
case SIGN_UP_LOCKED = 'locked';

/**
* Event sign-ups are stand-by only until manually approved by command staff.
*/
case MANUAL_SELECTION = 'manualselection';
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Features\Events\Commands;

use App\Bus\Contracts\CommandHandlerInterface;
use App\Enums\EventStatus;
use App\Enums\EventTrooperStatus;
use App\Mail\Events\TrooperSignUp;
use App\Models\EventTrooper;
Expand Down Expand Up @@ -45,14 +46,19 @@ public function __invoke(object $message): mixed
$event_trooper->added_by_trooper_id = $message->added_by_trooper->id == $message->trooper->id ? null : $message->added_by_trooper->id;
$status = EventTrooperStatus::GOING;

if ($event_trooper->is_handler)
if ($message->event_shift->event->status === EventStatus::MANUAL_SELECTION)
{
$status = EventTrooperStatus::STAND_BY;
}

if ($status !== EventTrooperStatus::STAND_BY && $event_trooper->is_handler)
{
if ($message->event_shift->handlersMaxed())
{
$status = EventTrooperStatus::STAND_BY;
}
}
else
elseif ($status !== EventTrooperStatus::STAND_BY)
{
if ($message->event_shift->troopersMaxed())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,14 @@ private function buildRelations(): array
$query->orderBy(EventShift::SHIFT_STARTS_AT, 'asc');
},
'event_shifts.event_guests.added_by_trooper:'.implode(',', $trooper_columns),
'event_shifts.event_guests.updated_by:'.implode(',', $trooper_columns),
'event_shifts.event_guests' => function ($query) {
$query->orderBy(EventGuest::NAME);
},
'event_shifts.event_troopers.trooper:'.implode(',', $trooper_columns),
'event_shifts.event_troopers.trooper.trooper_costumes.organization_costume',
'event_shifts.event_troopers.added_by_trooper:'.implode(',', $trooper_columns),
'event_shifts.event_troopers.updated_by:'.implode(',', $trooper_columns),
'event_shifts.event_troopers.costume:'.implode(',', $costume_columns),
'event_shifts.event_troopers.backup_costume:'.implode(',', $costume_columns),
'event_shifts.event_troopers' => function ($query) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,12 @@ private function buildRelations(): array
'event',
'event_troopers.trooper:'.implode(',', $trooper_columns),
'event_troopers.added_by_trooper:'.implode(',', $trooper_columns),
'event_troopers.updated_by:'.implode(',', $trooper_columns),
'event_troopers' => function ($query) {
$query->orderBy(EventTrooper::SIGNED_UP_AT, 'asc');
},
'event_guests.added_by_trooper:'.implode(',', $trooper_columns),
'event_guests.updated_by:'.implode(',', $trooper_columns),
'event_guests' => function ($query) {
$query->orderBy(EventGuest::NAME, 'asc');
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,13 @@ private function buildRelations(): array

$with = [
'event_troopers.trooper:'.implode(',', $trooper_columns),
'event_troopers.updated_by:'.implode(',', $trooper_columns),
'event_troopers.trooper.trooper_costumes:'.implode(',', $trooper_costume_columns),
'event_troopers.trooper.trooper_costumes.organization_costume:'.implode(',', $organization_costume_columns),
'event_troopers.costume:'.implode(',', $costume_columns),
'event_troopers.backup_costume:'.implode(',', $costume_columns),
'event_guests.added_by_trooper:'.implode(',', $trooper_columns),
'event_guests.updated_by:'.implode(',', $trooper_columns),
];

return $with;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ public function __invoke(

$this->bus->send(new UpdateEventCommand($event, $request->validated()));

if ($event->status == EventStatus::OPEN || $event->status == EventStatus::SIGN_UP_LOCKED)
{
if (
$event->status == EventStatus::OPEN
|| $event->status == EventStatus::MANUAL_SELECTION
|| $event->status == EventStatus::SIGN_UP_LOCKED
) {
dispatch(new SendEventCreatedNotificationsJob($event));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ public function __invoke(
{
if ($current_status == EventStatus::DRAFT)
{
if ($updated_status == EventStatus::OPEN || $updated_status == EventStatus::SIGN_UP_LOCKED)
{
if (
$updated_status == EventStatus::OPEN
|| $updated_status == EventStatus::MANUAL_SELECTION
|| $updated_status == EventStatus::SIGN_UP_LOCKED
) {
dispatch(new SendEventCreatedNotificationsJob($event));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@

namespace App\Http\Controllers\Admin\Events;

use App\Enums\EventStatus;
use App\Enums\EventTrooperStatus;
use App\Http\Controllers\MagicBusController;
use App\Http\Requests\Admin\Events\UpdateTroopersRequest;
use App\Mail\Events\TrooperManualSelectionApproved;
use App\Mail\Events\TrooperManualSelectionStandBy;
use App\Models\Event;
use App\Models\EventGuest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;

/**
* Processes trooper status update form submissions.
Expand All @@ -34,8 +39,15 @@ public function __invoke(UpdateTroopersRequest $request, Event $event): Redirect
$this->authorize('update', $event);

$troopers = $request->validated('troopers', []);
$guests = $request->validated('guests', []);

$event_troopers = $event->troopers()->get();
$event_guest_shift_ids = $event->event_shifts()->pluck('id');
$event_guests = EventGuest::query()
->whereIn(EventGuest::EVENT_SHIFT_ID, $event_guest_shift_ids)
->get();
$authTrooper = $request->user();
$isManualSelectionEvent = $event->status === EventStatus::MANUAL_SELECTION;

foreach ($troopers as $id => $input)
{
Expand All @@ -46,9 +58,53 @@ public function __invoke(UpdateTroopersRequest $request, Event $event): Redirect
continue;
}

$event_trooper->status = $input['status'];
$newStatus = $input['status'] ?? null;
if ($newStatus === null)
{
continue;
}

$oldStatus = $event_trooper->status;

$event_trooper->status = $newStatus;

$event_trooper->save();

$wasManualApproval = $isManualSelectionEvent
&& $oldStatus === EventTrooperStatus::STAND_BY
&& $event_trooper->status === EventTrooperStatus::GOING;
$wasMovedToStandBy = $isManualSelectionEvent
&& $oldStatus === EventTrooperStatus::GOING
&& $event_trooper->status === EventTrooperStatus::STAND_BY;

if ($wasManualApproval)
{
Mail::to($event_trooper->trooper->email)->queue(new TrooperManualSelectionApproved($event_trooper, $authTrooper));
}

if ($wasMovedToStandBy)
{
Mail::to($event_trooper->trooper->email)->queue(new TrooperManualSelectionStandBy($event_trooper, $authTrooper));
}
}

foreach ($guests as $id => $input)
{
$event_guest = $event_guests->first(fn ($eg) => $eg->id === (int) $id);

if ($event_guest === null)
{
continue;
}

$newStatus = $input['status'] ?? null;
if ($newStatus === null)
{
continue;
}

$event_guest->status = $newStatus;
$event_guest->save();
}

$this->flash->updated($event);
Expand Down
84 changes: 79 additions & 5 deletions tracker-app/app/Http/Controllers/Api/MobileApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public function __construct(
*/
private const OPEN_EVENT_STATUSES = [
EventStatus::OPEN,
EventStatus::MANUAL_SELECTION,
EventStatus::SIGN_UP_LOCKED,
];

Expand Down Expand Up @@ -453,7 +454,7 @@ private function getEvent(Request $request): JsonResponse

/**
* action=get_roster_for_event
* Return all sign-ups (event_troopers) for an event with costume and trooper info.
* Return all sign-ups for an event (troopers + guests) in a legacy-compatible shape.
*/
private function getRosterForEvent(Request $request): JsonResponse
{
Expand Down Expand Up @@ -490,7 +491,43 @@ private function getRosterForEvent(Request $request): JsonResponse
];
});

return response()->json($roster);
$event_guests = EventGuest::whereHas('event_shift', fn ($q) => $q->where(EventShift::EVENT_ID, $event_id))
->with('event_shift')
->orderBy(EventGuest::SIGNED_UP_AT)
->get();

$guest_roster = $event_guests->map(fn ($guest) => [
'id' => $guest->id,
'trooperid' => null,
'troopid' => $guest->event_shift->event_id,
'shift_id' => $guest->event_shift_id,
'shift_display' => $guest->event_shift->time_display,
'status' => $guest->status->value,
'status_formatted' => match ($guest->status)
{
EventGuestStatus::STAND_BY => 'Stand By',
EventGuestStatus::GOING => 'Going',
EventGuestStatus::TENTATIVE => 'Tentative',
EventGuestStatus::CANCELLED => 'Cancelled',
},
'costume' => null,
'costume_name' => null,
'backup_costume' => null,
'backup_costume_name' => null,
'is_handler' => false,
'trooper_name' => $guest->name,
'tkid' => null,
'tkid_formatted' => 'Guest',
'squad' => null,
'signuptime' => $guest->signed_up_at?->format('Y-m-d H:i:s'),
]);

$combined_roster = $roster
->concat($guest_roster)
->sortBy('signuptime')
->values();

return response()->json($combined_roster);
}

/**
Expand Down Expand Up @@ -776,6 +813,14 @@ private function cancelShift(Request $request): JsonResponse
return response()->json(['error' => 'Shift not found.'], 404);
}

if ($shift->event->status === EventStatus::MANUAL_SELECTION)
{
return response()->json([
'success' => false,
'message' => 'Manual Selection events do not allow cancellations from mobile.',
], 403);
}

if ($friend_trooper_id > 0)
{
// Cancelling a friend — verify the requester added them
Expand Down Expand Up @@ -857,6 +902,20 @@ private function cancelTroop(Request $request): JsonResponse
return response()->json(['error' => 'Trooper not found.'], 404);
}

$event = Event::find($event_id);
if (!$event)
{
return response()->json(['error' => 'Event not found.'], 404);
}

if ($event->status === EventStatus::MANUAL_SELECTION)
{
return response()->json([
'success' => false,
'message' => 'Manual Selection events do not allow cancellations from mobile.',
], 403);
}

EventTrooper::where(EventTrooper::TROOPER_ID, $trooper->id)
->whereHas('event_shift', fn ($q) => $q->where(EventShift::EVENT_ID, $event_id))
->update([EventTrooper::STATUS => EventTrooperStatus::CANCELLED->value]);
Expand Down Expand Up @@ -913,6 +972,10 @@ private function signUp(Request $request): JsonResponse

$existing = $this->findExistingSignUp($trooper->id, $event_id, $shift_id);

$effective_status = $event->status === EventStatus::MANUAL_SELECTION
? EventTrooperStatus::STAND_BY
: $requested_status;

if ($existing)
{
// Re-activating a cancelled friend signup still counts against the limit.
Expand All @@ -926,7 +989,7 @@ private function signUp(Request $request): JsonResponse
}

$existing->update([
EventTrooper::STATUS => $requested_status->value,
EventTrooper::STATUS => $effective_status->value,
EventTrooper::COSTUME_ID => $costume_id ?: null,
EventTrooper::BACKUP_COSTUME_ID => $backup_costume_id ?: null,
]);
Expand All @@ -939,7 +1002,7 @@ private function signUp(Request $request): JsonResponse
}

$is_handler = $this->isHandlerCostume($costume_id);
$status = $this->resolveCapacityStatus($event, $event_id, $is_handler, $requested_status);
$status = $this->resolveCapacityStatus($event, $event_id, $is_handler, $effective_status);
$shift = $this->resolveShiftForSignUp($event_id, $shift_id);

if (!$shift)
Expand Down Expand Up @@ -1117,7 +1180,9 @@ private function addGuest(Request $request): JsonResponse
[EventGuest::EVENT_SHIFT_ID => $shift->id, EventGuest::NAME => $name],
[
EventGuest::ADDED_BY_TROOPER_ID => $trooper->id,
EventGuest::STATUS => EventGuestStatus::GOING->value,
EventGuest::STATUS => $shift->event->status === EventStatus::MANUAL_SELECTION
? EventGuestStatus::STAND_BY->value
: EventGuestStatus::GOING->value,
EventGuest::SIGNED_UP_AT => now(),
]
);
Expand All @@ -1142,13 +1207,22 @@ private function cancelGuest(Request $request): JsonResponse

$guest = EventGuest::where(EventGuest::ID, $guest_id)
->where(EventGuest::ADDED_BY_TROOPER_ID, $trooper->id)
->with('event_shift.event')
->first();

if (!$guest)
{
return response()->json(['success' => false, 'message' => 'Guest not found or not authorized.']);
}

if ($guest->event_shift->event->status === EventStatus::MANUAL_SELECTION)
{
return response()->json([
'success' => false,
'message' => 'Manual Selection events do not allow cancellations from mobile.',
], 403);
}

$guest->update([EventGuest::STATUS => EventGuestStatus::CANCELLED->value]);

return response()->json(['success' => true]);
Expand Down
Loading
Loading