Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
10e3df7
seminar
CLC0609 Jul 3, 2026
89cf400
further validation checks
CLC0609 Jul 3, 2026
e891f96
Potential fix for pull request finding
CLC0609 Jul 3, 2026
05e5533
Fixes
CLC0609 Jul 3, 2026
bbc5f56
Merge branch 'tech-329-automate-obs-s1-seminar-arrangement' of https:…
CLC0609 Jul 3, 2026
d3b6b6a
update isResponded to only have actual responded enums
CLC0609 Jul 3, 2026
6944aeb
Merge branch 'main' into tech-329-automate-obs-s1-seminar-arrangement
CLC0609 Jul 9, 2026
7d55eee
fixes
CLC0609 Jul 11, 2026
8c5a8a4
Some QOL changes
CLC0609 Jul 12, 2026
25bd81f
Merge branch 'main' into tech-329-automate-obs-s1-seminar-arrangement
CLC0609 Jul 24, 2026
62fdf35
Merge branch 'main' into tech-329-automate-obs-s1-seminar-arrangement
CLC0609 Jul 31, 2026
720a4ba
Merge branch 'main' into tech-329-automate-obs-s1-seminar-arrangement
CLC0609 Jul 31, 2026
0124f55
adjust to work with new polymorphic training positions
CLC0609 Jul 31, 2026
a1c1dae
Update app/Filament/Training/Resources/Seminars/Pages/EditSeminar.php
CLC0609 Jul 31, 2026
07a33ff
Update app/Filament/Training/Resources/Seminars/Pages/ViewSeminar.php
CLC0609 Jul 31, 2026
3a5cb9e
Update EditSeminar.php
CLC0609 Jul 31, 2026
7d38c4d
Update app/Filament/Training/Resources/Seminars/SeminarResource.php
CLC0609 Aug 1, 2026
27b44c8
Update tests/Feature/Training/Seminar/CheckForExpiredSeminarInvitatio…
CLC0609 Aug 1, 2026
4cbc757
Update app/Filament/Training/Resources/Seminars/SeminarResource.php
CLC0609 Aug 2, 2026
4584791
Update database/migrations/2026_07_02_100010_create_training_seminar_…
CLC0609 Aug 2, 2026
8e459b6
add invitation column
CLC0609 Aug 2, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Console\Commands\Training;

use App\Services\Training\SeminarInvitationService;
use Illuminate\Console\Command;

class CheckForExpiredSeminarInvitations extends Command
{
protected $signature = 'training:check-for-expired-seminar-invitations';

protected $description = 'Process seminar invitations that have expired without a response';

public function handle(SeminarInvitationService $service): int
{
$service->expireUnrespondedInvitations();

return Command::SUCCESS;
}
}
4 changes: 4 additions & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ protected function schedule(Schedule $schedule)
->dailyAt('11:00')
->graceTimeInMinutes(15);

$schedule->command('training:check-for-expired-seminar-invitations')
->dailyAt('11:30')
->graceTimeInMinutes(15);

// === By Quarter === //
$schedule->command('roster:update', [
Carbon::now()->subMonths(3),
Expand Down
48 changes: 48 additions & 0 deletions app/Enums/SeminarInvitationStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum SeminarInvitationStatus: string
{
case Sent = 'sent';
case Attending = 'attending';
case NotInterested = 'not_interested';
case CannotAttend = 'cannot_attend';
case RemovedNoResponse = 'removed_no_response';
case RemovedTwoCannotAttend = 'removed_two_cannot_attend';

public function label(): string
{
return match ($this) {
self::Sent => 'Sent',
self::Attending => 'Attending',
self::NotInterested => 'Not Interested',
self::CannotAttend => 'Cannot Attend',
self::RemovedNoResponse => 'Removed (No Response)',
self::RemovedTwoCannotAttend => 'Removed (Two Cannot Attend)',
};
}

public function color(): string
{
return match ($this) {
self::Sent => 'gray',
self::Attending => 'success',
self::NotInterested => 'danger',
self::CannotAttend => 'warning',
self::RemovedNoResponse => 'danger',
self::RemovedTwoCannotAttend => 'danger',
};
}

public function isResponded(): bool
{
Comment thread
CLC0609 marked this conversation as resolved.
return in_array($this, [
self::Attending,
self::NotInterested,
self::CannotAttend,
], true);
}
}
25 changes: 25 additions & 0 deletions app/Filament/Training/Resources/Seminars/Pages/CreateSeminar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Filament\Training\Resources\Seminars\Pages;

use App\Filament\Training\Resources\Seminars\SeminarResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\CreateRecord;

class CreateSeminar extends CreateRecord
{
protected static string $resource = SeminarResource::class;

protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by'] = auth()->id();

return $data;
}

protected function getCreateAnotherFormAction(): Action
{
return parent::getCreateAnotherFormAction()
->hidden();
}
}
11 changes: 11 additions & 0 deletions app/Filament/Training/Resources/Seminars/Pages/EditSeminar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Filament\Training\Resources\Seminars\Pages;

use App\Filament\Training\Resources\Seminars\SeminarResource;
use Filament\Resources\Pages\EditRecord;

class EditSeminar extends EditRecord
{
protected static string $resource = SeminarResource::class;
}
Comment thread
CLC0609 marked this conversation as resolved.
19 changes: 19 additions & 0 deletions app/Filament/Training/Resources/Seminars/Pages/ListSeminars.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace App\Filament\Training\Resources\Seminars\Pages;

use App\Filament\Training\Resources\Seminars\SeminarResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;

class ListSeminars extends ListRecords
{
protected static string $resource = SeminarResource::class;

protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
96 changes: 96 additions & 0 deletions app/Filament/Training/Resources/Seminars/Pages/ViewSeminar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace App\Filament\Training\Resources\Seminars\Pages;

use App\Filament\Training\Resources\Seminars\SeminarResource;
use App\Filament\Training\Resources\Seminars\Widgets\SeminarStatsOverview;
use App\Services\Training\SeminarInvitationService;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;

class ViewSeminar extends ViewRecord
{
protected static string $resource = SeminarResource::class;

protected function getHeaderActions(): array
{
return [
Action::make('autoInvite')
->label('Enable Automatic Invitations')
->icon('heroicon-o-paper-airplane')
->color('primary')
->requiresConfirmation()
->modalHeading('Enable Automatic Invitations?')
->modalDescription(
'Automatic invitations will be enabled for this seminar. '.
'The system will immediately send invitations to eligible students until all available seminar places have been filled. '.
'Additional invitations will also be sent automatically if places become available.'
)
->modalSubmitActionLabel('Enable')
->action(function (): void {
DB::transaction(function () {
$this->record->update(['automatic_invitations_enabled' => true]);
app(SeminarInvitationService::class)->topUpAutomaticInvitations($this->record);
});
})
Comment thread
CLC0609 marked this conversation as resolved.
->visible(fn () => ! $this->record->isSendingCutoffReached() && ! $this->record->automatic_invitations_enabled && auth()->user()->can('training.seminars.manage.*')),
Comment thread
CLC0609 marked this conversation as resolved.

Action::make('disableAutoInvite')
->label('Disable Automatic Invitations')
->icon('heroicon-o-pause')
->color('gray')
->requiresConfirmation()
->modalHeading('Disable Automatic Invitations?')
->modalDescription(
'No further invitations will be sent automatically for this seminar. '.
'Students who have already been invited or booked will not be affected. '.
'You can re-enable automatic invitations at any time.'
)
->modalSubmitActionLabel('Disable')
->action(function (): void {
$this->record->update(['automatic_invitations_enabled' => false]);
})
->visible(fn () => $this->record->automatic_invitations_enabled && auth()->user()->can('training.seminars.manage.*')),

Action::make('manualClose')
->label(fn () => $this->record->closed_at ? 'Closed' : 'Close Seminar')
->icon('heroicon-o-lock-closed')
->color(fn () => $this->record->closed_at ? 'gray' : 'danger')
->disabled(fn () => (bool) $this->record->closed_at)
->requiresConfirmation()
->modalHeading('Close Seminar?')
->modalDescription(
'This will permanently close the seminar. '.
'No further invitations can be sent or responded to. '
)
->modalSubmitActionLabel('Close Seminar')
->action(function (): void {
$this->record->update([
'automatic_invitations_enabled' => false,
'closed_at' => now(),
]);
})
->visible(fn () => auth()->user()->can('training.seminars.manage.*')),

ActionGroup::make([
EditAction::make()
->label('Edit Seminar')
->visible(fn () => auth()->user()->can('training.seminars.manage.*')),
])
->label('Actions')
->icon('heroicon-o-cog-6-tooth')
->color('gray')
->visible(fn () => auth()->user()->can('training.seminars.manage.*')),

];
}

protected function getHeaderWidgets(): array
{
return [
SeminarStatsOverview::class,
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

namespace App\Filament\Training\Resources\Seminars\RelationManagers;

use App\Filament\Training\Pages\TrainingPlace\ViewTrainingPlace;
use App\Models\Training\Seminar\SeminarAttendee;
use App\Models\Training\TrainingPlace\TrainingPlace;
use App\Models\Training\TrainingPosition\TrainingPosition;
use App\Models\Training\WaitingList\WaitingListAccount; // Imported TrainingPlace Model
use App\Services\Training\TrainingPlaceService;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class AttendeesRelationManager extends RelationManager
{
protected static string $relationship = 'attendees';

protected static ?string $title = 'Attendees';

public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with(['account', 'invitation']))
Comment thread
CLC0609 marked this conversation as resolved.
->columns([
TextColumn::make('account_id')->label('CID'),
TextColumn::make('account.name')->label('Name')->searchable(['name_first', 'name_last']),
])
->recordActions([
Action::make('createTrainingPlace')
->label(fn (SeminarAttendee $record) => $this->hasTrainingPlace($record) ? 'Training Place Already Exists' : 'Create Training Place')
->icon('heroicon-o-academic-cap')
->modalDescription('Manually create a training place for this student. This does not send an offer email.')
->color(fn (SeminarAttendee $record) => $this->hasTrainingPlace($record) ? 'gray' : 'success')
->disabled(fn (SeminarAttendee $record) => $this->hasTrainingPlace($record))
->visible(fn () => auth()->user()->can('training.seminars.manage.*')
&& ($this->ownerRecord->relationLoaded('waitingList')
? $this->ownerRecord->waitingList !== null
: $this->ownerRecord->waitingList()->exists()))
->schema([
Select::make('training_position_id')
->label('Training Position')
->options(fn () => $this->ownerRecord
->loadMissing('waitingList.trainingPositions.position')
->waitingList?->trainingPositions
->mapWithKeys(fn ($tp) => [$tp->id => $tp->position?->callsign ?? "Position #{$tp->id}"])
->toArray() ?? [])
->required()
->helperText('Select the training position to assign to this member.'),
])
->action(function (SeminarAttendee $record, array $data) {
$waitingListAccount = WaitingListAccount::query()
->where('list_id', $record->seminar->waiting_list_id)
->where('account_id', $record->account_id)
->first();

if (! $waitingListAccount) {
Notification::make()
->title('Cannot create training place')
->body('This member is not on the waiting list for this seminar.')
->danger()
->send();

return;
}

$trainingPosition = TrainingPosition::query()->findOrFail($data['training_position_id']);
$service = app(TrainingPlaceService::class);
$trainingPlace = $service->createManualTrainingPlace($waitingListAccount, $trainingPosition);
Comment thread
CLC0609 marked this conversation as resolved.
Comment thread
CLC0609 marked this conversation as resolved.

Notification::make()
->title('Training place created')
->success()
->actions([
Action::make('view')
->label('View Training Place')
->url(ViewTrainingPlace::getUrl(['trainingPlaceId' => $trainingPlace->id]))
->markAsRead(),
])
->send();
}),
]);
}

protected function hasTrainingPlace(SeminarAttendee $record): bool
{
$availablePositionIds = $this->ownerRecord
->loadMissing('waitingList.trainingPositions')
->waitingList?->trainingPositions
->pluck('id')
->toArray() ?? [];

if (empty($availablePositionIds)) {
return false;
}

return TrainingPlace::query()
->where('account_id', $record->account_id)
->where('trainable_type', TrainingPosition::class)
->whereIn('trainable_id', $availablePositionIds)
->exists();
}
Comment thread
CLC0609 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace App\Filament\Training\Resources\Seminars\RelationManagers;

use App\Enums\SeminarInvitationStatus;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class InvitationsRelationManager extends RelationManager
{
protected static string $relationship = 'invitations';

protected static ?string $title = 'Invitations';

public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('account'))
->columns([
TextColumn::make('account_id')->label('CID')->searchable(),
TextColumn::make('account.name')->label('Name')->searchable(['name_first', 'name_last']),
TextColumn::make('status')
->badge()
->label('Status')
->formatStateUsing(fn ($state) => $state?->label())
->color(fn ($state) => $state?->color()),
TextColumn::make('sent_at')->dateTime('d/m/Y H:i')->sortable(),
TextColumn::make('responded_at')->dateTime('d/m/Y H:i')->sortable(),
])
->filters([
SelectFilter::make('status')
->options(collect(SeminarInvitationStatus::cases())->mapWithKeys(fn ($status) => [
$status->value => $status->label(),
]))
->multiple(),

Filter::make('responded')
->query(fn (Builder $query) => $query->whereIn('status', [
SeminarInvitationStatus::Attending->value,
SeminarInvitationStatus::NotInterested->value,
SeminarInvitationStatus::CannotAttend->value,
])),
]);
}
}
Loading