initial
All checks were successful
linter / quality (push) Successful in 1m37s
tests / ci (8.4) (push) Successful in 2m13s
tests / ci (8.5) (push) Successful in 1m25s

This commit is contained in:
Tim Basten
2026-03-05 11:41:39 +08:00
commit 564f78dcda
182 changed files with 21145 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ApprovalStatus: string
{
case Pending = 'Pending';
case Approved = 'Approved';
case Rejected = 'Rejected';
}

21
app/Enums/EventType.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum EventType: string
{
case AnnualInPersonMeeting = 'AnnualInPersonMeeting';
case Conference = 'Conference';
case Osces = 'Osces';
case ResearchRetreat = 'ResearchRetreat';
public function label(): string
{
return match($this) {
self::AnnualInPersonMeeting => 'Annual In-Person Meeting',
self::Conference => 'Conference',
self::Osces => 'OSCEs',
self::ResearchRetreat => 'Research Retreat',
};
}
}

25
app/Enums/GeneralType.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
namespace App\Enums;
enum GeneralType: string
{
case Aso = 'Aso';
case Hubs = 'Hubs';
case Management = 'Management';
case Mc = 'Mc';
case Other = 'Other';
case Research = 'Research';
public function label(): string
{
return match($this) {
self::Aso => 'ASO',
self::Hubs => 'Hubs',
self::Management => 'Management',
self::Mc => 'MC',
self::Other => 'Other',
self::Research => 'Research',
};
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Enums;
enum JourneyMethod: string
{
case Air = 'Air';
case Bus = 'Bus';
case PersonalVehicle = 'PersonalVehicle';
case RcswaVehicle = 'RcswaVehicle';
case Train = 'Train';
public function label(): string
{
return match($this) {
self::Air => 'Air',
self::Bus => 'Bus',
self::PersonalVehicle => 'Personal Vehicle',
self::RcswaVehicle => 'RCSWA Vehicle',
self::Train => 'Train',
};
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum TravelStatus: string
{
case Draft = 'Draft';
case Pending = 'Pending';
case Approved = 'Approved';
case Rejected = 'Rejected';
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\ApprovalWorkflows;
use App\Filament\Resources\ApprovalWorkflows\Pages\CreateApprovalWorkflow;
use App\Filament\Resources\ApprovalWorkflows\Pages\EditApprovalWorkflow;
use App\Filament\Resources\ApprovalWorkflows\Pages\ListApprovalWorkflows;
use App\Filament\Resources\ApprovalWorkflows\Schemas\ApprovalWorkflowForm;
use App\Filament\Resources\ApprovalWorkflows\Tables\ApprovalWorkflowsTable;
use App\Models\ApprovalWorkflow;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class ApprovalWorkflowResource extends Resource
{
protected static ?string $model = ApprovalWorkflow::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return ApprovalWorkflowForm::configure($schema);
}
public static function table(Table $table): Table
{
return ApprovalWorkflowsTable::configure($table);
}
public static function getRelations(): array
{
return [
RelationManagers\StepsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListApprovalWorkflows::route('/'),
'create' => CreateApprovalWorkflow::route('/create'),
'edit' => EditApprovalWorkflow::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\ApprovalWorkflows\Pages;
use App\Filament\Resources\ApprovalWorkflows\ApprovalWorkflowResource;
use Filament\Resources\Pages\CreateRecord;
class CreateApprovalWorkflow extends CreateRecord
{
protected static string $resource = ApprovalWorkflowResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ApprovalWorkflows\Pages;
use App\Filament\Resources\ApprovalWorkflows\ApprovalWorkflowResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditApprovalWorkflow extends EditRecord
{
protected static string $resource = ApprovalWorkflowResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ApprovalWorkflows\Pages;
use App\Filament\Resources\ApprovalWorkflows\ApprovalWorkflowResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListApprovalWorkflows extends ListRecords
{
protected static string $resource = ApprovalWorkflowResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Resources\ApprovalWorkflows\RelationManagers;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class StepsRelationManager extends RelationManager
{
protected static string $relationship = 'steps';
public function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('order')
->required()
->numeric()
->minValue(1),
TextInput::make('name')
->required()
->maxLength(255),
Select::make('role')
->required()
->options([
'travel_approver' => 'Travel Approver',
'administrator' => 'Administrator',
]),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
TextColumn::make('order')->sortable(),
TextColumn::make('name'),
TextColumn::make('role'),
])
->defaultSort('order')
->headerActions([
CreateAction::make(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Filament\Resources\ApprovalWorkflows\Schemas;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class ApprovalWorkflowForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->maxLength(255),
Textarea::make('description')
->maxLength(1000)
->rows(3),
Toggle::make('is_active')
->default(true),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Filament\Resources\ApprovalWorkflows\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ApprovalWorkflowsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('description')->limit(60)->toggleable(),
IconColumn::make('is_active')->boolean(),
TextColumn::make('steps_count')->counts('steps')->label('Steps'),
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\TravelRequests\Pages;
use App\Filament\Resources\TravelRequests\TravelRequestResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTravelRequest extends CreateRecord
{
protected static string $resource = TravelRequestResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TravelRequests\Pages;
use App\Filament\Resources\TravelRequests\TravelRequestResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditTravelRequest extends EditRecord
{
protected static string $resource = TravelRequestResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TravelRequests\Pages;
use App\Filament\Resources\TravelRequests\TravelRequestResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListTravelRequests extends ListRecords
{
protected static string $resource = TravelRequestResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\TravelRequests\Schemas;
use Filament\Schemas\Schema;
class TravelRequestForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
//
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Filament\Resources\TravelRequests\Tables;
use App\Enums\TravelStatus;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TravelRequestsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')->sortable(),
TextColumn::make('user.name')->label('Applicant')->searchable()->sortable(),
TextColumn::make('reason_summary')->limit(50)->label('Reason'),
TextColumn::make('status')->badge()
->color(fn (TravelStatus $state) => match($state) {
TravelStatus::Draft => 'gray',
TravelStatus::Pending => 'warning',
TravelStatus::Approved => 'success',
TravelStatus::Rejected => 'danger',
}),
TextColumn::make('submitted_at')->dateTime()->sortable(),
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->options(collect(TravelStatus::cases())->mapWithKeys(fn ($e) => [$e->value => $e->value])->toArray()),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\TravelRequests;
use App\Filament\Resources\TravelRequests\Pages\CreateTravelRequest;
use App\Filament\Resources\TravelRequests\Pages\EditTravelRequest;
use App\Filament\Resources\TravelRequests\Pages\ListTravelRequests;
use App\Filament\Resources\TravelRequests\Schemas\TravelRequestForm;
use App\Filament\Resources\TravelRequests\Tables\TravelRequestsTable;
use App\Models\TravelRequest;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class TravelRequestResource extends Resource
{
protected static ?string $model = TravelRequest::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return TravelRequestForm::configure($schema);
}
public static function table(Table $table): Table
{
return TravelRequestsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListTravelRequests::route('/'),
'create' => CreateTravelRequest::route('/create'),
'edit' => EditTravelRequest::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Resources\Users\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Spatie\Permission\Models\Role;
class UserForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('email')
->email()
->required()
->maxLength(255),
TextInput::make('username')
->maxLength(255),
TextInput::make('phone')
->maxLength(50),
TextInput::make('department')
->maxLength(255),
TextInput::make('title')
->maxLength(255),
Select::make('roles')
->relationship('roles', 'name')
->multiple()
->preload(),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Filament\Resources\Users\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class UsersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('email')->searchable()->sortable(),
TextColumn::make('username')->searchable(),
TextColumn::make('department')->sortable(),
TextColumn::make('roles.name')->badge(),
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\Users;
use App\Filament\Resources\Users\Pages\CreateUser;
use App\Filament\Resources\Users\Pages\EditUser;
use App\Filament\Resources\Users\Pages\ListUsers;
use App\Filament\Resources\Users\Schemas\UserForm;
use App\Filament\Resources\Users\Tables\UsersTable;
use App\Models\User;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema);
}
public static function table(Table $table): Table
{
return UsersTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListUsers::route('/'),
'create' => CreateUser::route('/create'),
'edit' => EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Jobs;
use App\Mail\ApprovalDecisionMail;
use App\Models\TravelRequest;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendApprovalDecisionEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly TravelRequest $travelRequest,
) {}
public function handle(): void
{
Mail::to($this->travelRequest->user->email)
->queue(new ApprovalDecisionMail($this->travelRequest));
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Jobs;
use App\Mail\ApprovalRequestMail;
use App\Models\TravelRequest;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
class SendApprovalRequestEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly TravelRequest $travelRequest,
public readonly string $role,
) {}
public function handle(): void
{
$approvers = User::role($this->role)->get();
foreach ($approvers as $approver) {
$signedUrl = URL::signedRoute('travel-requests.approve', ['id' => $this->travelRequest->id]);
Mail::to($approver->email)->queue(new ApprovalRequestMail($this->travelRequest, $approver, $signedUrl));
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Mail;
use App\Models\TravelRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ApprovalDecisionMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly TravelRequest $travelRequest,
) {}
public function envelope(): Envelope
{
$status = $this->travelRequest->status->value;
return new Envelope(
subject: 'Travel Request #' . $this->travelRequest->id . ' ' . $status,
);
}
public function content(): Content
{
return new Content(
view: 'mail.approval-decision',
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Mail;
use App\Models\TravelRequest;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ApprovalRequestMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly TravelRequest $travelRequest,
public readonly User $approver,
public readonly string $signedUrl,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Action Required: Travel Request #' . $this->travelRequest->id . ' Awaiting Your Approval',
);
}
public function content(): Content
{
return new Content(
view: 'mail.approval-request',
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ApprovalStep extends Model
{
/** @use HasFactory<\Database\Factories\ApprovalStepFactory> */
use HasFactory;
protected $fillable = [
'workflow_id',
'order',
'name',
'role',
];
public function workflow(): BelongsTo
{
return $this->belongsTo(ApprovalWorkflow::class, 'workflow_id');
}
public function approvals(): HasMany
{
return $this->hasMany(TravelRequestApproval::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ApprovalWorkflow extends Model
{
/** @use HasFactory<\Database\Factories\ApprovalWorkflowFactory> */
use HasFactory;
protected $fillable = [
'name',
'description',
'is_active',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
public function steps(): HasMany
{
return $this->hasMany(ApprovalStep::class, 'workflow_id')->orderBy('order');
}
public function travelRequests(): HasMany
{
return $this->hasMany(TravelRequest::class, 'workflow_id');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmergencyContact extends Model
{
/** @use HasFactory<\Database\Factories\EmergencyContactFactory> */
use HasFactory;
protected $fillable = [
'user_id',
'full_name',
'phone_number',
'relationship',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TravelCostCode extends Model
{
/** @use HasFactory<\Database\Factories\TravelCostCodeFactory> */
use HasFactory;
protected $fillable = [
'travel_request_id',
'business_unit',
'project_grant',
'account_code',
'class_code',
];
public function travelRequest(): BelongsTo
{
return $this->belongsTo(TravelRequest::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use App\Enums\JourneyMethod;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TravelJourney extends Model
{
/** @use HasFactory<\Database\Factories\TravelJourneyFactory> */
use HasFactory;
protected $fillable = [
'travel_request_id',
'origin',
'destination',
'date',
'time',
'method',
];
protected function casts(): array
{
return [
'method' => JourneyMethod::class,
'date' => 'date',
];
}
public function travelRequest(): BelongsTo
{
return $this->belongsTo(TravelRequest::class);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Models;
use App\Enums\EventType;
use App\Enums\GeneralType;
use App\Enums\TravelStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class TravelRequest extends Model
{
/** @use HasFactory<\Database\Factories\TravelRequestFactory> */
use HasFactory;
protected $fillable = [
'user_id',
'workflow_id',
'status',
'reason_summary',
'event_type',
'general_type',
'needs_accommodation',
'needs_car_hire',
'vehicle_policy_acknowledged',
'business_days',
'private_days',
'additional_notes',
'submitted_at',
];
protected function casts(): array
{
return [
'status' => TravelStatus::class,
'event_type' => EventType::class,
'general_type' => GeneralType::class,
'needs_accommodation' => 'boolean',
'needs_car_hire' => 'boolean',
'vehicle_policy_acknowledged' => 'boolean',
'submitted_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function workflow(): BelongsTo
{
return $this->belongsTo(ApprovalWorkflow::class, 'workflow_id');
}
public function journeys(): HasMany
{
return $this->hasMany(TravelJourney::class);
}
public function costCodes(): HasMany
{
return $this->hasMany(TravelCostCode::class);
}
public function approvals(): HasMany
{
return $this->hasMany(TravelRequestApproval::class);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Enums\ApprovalStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TravelRequestApproval extends Model
{
/** @use HasFactory<\Database\Factories\TravelRequestApprovalFactory> */
use HasFactory;
protected $fillable = [
'travel_request_id',
'approval_step_id',
'approver_id',
'status',
'comments',
'acted_at',
];
protected function casts(): array
{
return [
'status' => ApprovalStatus::class,
'acted_at' => 'datetime',
];
}
public function travelRequest(): BelongsTo
{
return $this->belongsTo(TravelRequest::class);
}
public function step(): BelongsTo
{
return $this->belongsTo(ApprovalStep::class, 'approval_step_id');
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approver_id');
}
}

76
app/Models/User.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
use LdapRecord\Laravel\Auth\HasLdapUser;
use LdapRecord\Laravel\Auth\LdapAuthenticatable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements FilamentUser, LdapAuthenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasRoles, AuthenticatesWithLdap, HasLdapUser;
/**
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'username',
'phone',
'department',
'title',
'ldap_guid',
'ldap_domain',
];
/**
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function initials(): string
{
return Str::of($this->name)
->explode(' ')
->take(2)
->map(fn ($word) => Str::substr($word, 0, 1))
->implode('');
}
public function canAccessPanel(Panel $panel): bool
{
return $this->hasRole('administrator');
}
public function emergencyContacts(): HasMany
{
return $this->hasMany(EmergencyContact::class);
}
public function travelRequests(): HasMany
{
return $this->hasMany(TravelRequest::class);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Policies;
use App\Enums\TravelStatus;
use App\Models\TravelRequest;
use App\Models\User;
class TravelRequestPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, TravelRequest $travelRequest): bool
{
return $user->id === $travelRequest->user_id
|| $user->hasAnyRole(['travel_approver', 'administrator']);
}
public function create(User $user): bool
{
return $user->hasAnyRole(['staff', 'travel_approver', 'administrator']);
}
public function update(User $user, TravelRequest $travelRequest): bool
{
return $user->id === $travelRequest->user_id
&& $travelRequest->status === TravelStatus::Draft;
}
public function approve(User $user, TravelRequest $travelRequest): bool
{
return $user->hasAnyRole(['travel_approver', 'administrator'])
&& $travelRequest->status === TravelStatus::Pending;
}
public function delete(User $user, TravelRequest $travelRequest): bool
{
return $user->hasRole('administrator');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Providers;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureDefaults();
}
/**
* Configure default behaviors for production-ready applications.
*/
protected function configureDefaults(): void
{
Date::use(CarbonImmutable::class);
DB::prohibitDestructiveCommands(
app()->isProduction(),
);
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()
->letters()
->numbers()
->symbols()
->uncompromised()
: null,
);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Providers\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login(false)
->authGuard('web')
->colors([
'primary' => Color::Amber,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
AccountWidget::class,
FilamentInfoWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Services;
use App\Enums\ApprovalStatus;
use App\Enums\TravelStatus;
use App\Jobs\SendApprovalDecisionEmail;
use App\Jobs\SendApprovalRequestEmail;
use App\Models\TravelRequest;
use App\Models\TravelRequestApproval;
use App\Models\User;
class ApprovalService
{
public function submit(TravelRequest $travelRequest): void
{
$workflow = $travelRequest->workflow;
if (! $workflow) {
return;
}
$steps = $workflow->steps()->orderBy('order')->get();
foreach ($steps as $index => $step) {
TravelRequestApproval::create([
'travel_request_id' => $travelRequest->id,
'approval_step_id' => $step->id,
'approver_id' => null,
'status' => $index === 0 ? ApprovalStatus::Pending->value : null,
'comments' => null,
'acted_at' => null,
]);
}
$travelRequest->update([
'status' => TravelStatus::Pending,
'submitted_at' => now(),
]);
SendApprovalRequestEmail::dispatch($travelRequest, $steps->first()->role);
}
public function approve(TravelRequestApproval $approval, User $approver, ?string $comments): void
{
$approval->update([
'status' => ApprovalStatus::Approved,
'approver_id' => $approver->id,
'comments' => $comments,
'acted_at' => now(),
]);
$travelRequest = $approval->travelRequest;
$nextApproval = TravelRequestApproval::query()
->where('travel_request_id', $travelRequest->id)
->whereNull('status')
->with('step')
->orderBy('id')
->first();
if ($nextApproval) {
$nextApproval->update(['status' => ApprovalStatus::Pending->value]);
SendApprovalRequestEmail::dispatch($travelRequest, $nextApproval->step->role);
} else {
$travelRequest->update(['status' => TravelStatus::Approved]);
SendApprovalDecisionEmail::dispatch($travelRequest);
}
}
public function reject(TravelRequestApproval $approval, User $approver, ?string $comments): void
{
$approval->update([
'status' => ApprovalStatus::Rejected,
'approver_id' => $approver->id,
'comments' => $comments,
'acted_at' => now(),
]);
$approval->travelRequest->update(['status' => TravelStatus::Rejected]);
SendApprovalDecisionEmail::dispatch($approval->travelRequest);
}
}