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,95 @@
<?php
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Rule;
new #[Layout('components.layouts.guest')] class extends Component {
#[Rule('required|string')]
public string $username = '';
#[Rule('required|string')]
public string $password = '';
public bool $rememberMe = false;
public function login(): void
{
$this->validate();
if (Auth::attempt(['username' => $this->username, 'password' => $this->password], $this->rememberMe)) {
session()->regenerate();
$this->redirectIntended(route('dashboard'), navigate: true);
return;
}
$this->addError('username', 'These credentials do not match our records.');
}
}
?>
<div class="min-vh-100 d-flex align-items-center justify-content-center bg-light">
<div class="card shadow-sm" style="width: 100%; max-width: 420px;">
<div class="card-body p-4">
<div class="text-center mb-4">
<h4 class="fw-bold">Travel Request System</h4>
<p class="text-muted small">Sign in with your network credentials</p>
</div>
<form wire:submit="login">
@if ($errors->any())
<div class="alert alert-danger">
@foreach ($errors->all() as $error)
<div>{{ $error }}</div>
@endforeach
</div>
@endif
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input
type="text"
id="username"
wire:model="username"
class="form-control @error('username') is-invalid @enderror"
autocomplete="username"
autofocus
>
@error('username')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input
type="password"
id="password"
wire:model="password"
class="form-control @error('password') is-invalid @enderror"
autocomplete="current-password"
>
@error('password')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="mb-3 form-check">
<input type="checkbox" id="rememberMe" wire:model="rememberMe" class="form-check-input">
<label for="rememberMe" class="form-check-label">Remember me</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary" wire:loading.attr="disabled">
<span wire:loading.remove>Sign In</span>
<span wire:loading>
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
Signing in...
</span>
</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,94 @@
<?php
use App\Enums\TravelStatus;
use App\Models\TravelRequest;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app')] class extends Component {
use WithPagination;
public function render(): mixed
{
$user = Auth::user();
$query = TravelRequest::query()->with(['user', 'approvals']);
if (! $user->hasAnyRole(['travel_approver', 'administrator'])) {
$query->where('user_id', $user->id);
}
return view('livewire.dashboard', [
'requests' => $query->latest()->paginate(15),
'statuses' => TravelStatus::cases(),
]);
}
}
?>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">Travel Requests</h2>
<a href="{{ route('travel-requests.create') }}" class="btn btn-primary">
+ New Request
</a>
</div>
@if ($requests->isEmpty())
<div class="card">
<div class="card-body text-center py-5 text-muted">
<p class="mb-0">No travel requests yet.</p>
<a href="{{ route('travel-requests.create') }}" class="btn btn-primary mt-3">Submit Your First Request</a>
</div>
</div>
@else
<div class="card">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Applicant</th>
<th>Reason</th>
<th>Status</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach ($requests as $request)
<tr>
<td class="text-muted">{{ $request->id }}</td>
<td>{{ $request->user->name }}</td>
<td>{{ Str::limit($request->reason_summary, 60) }}</td>
<td>
@php
$badgeClass = match($request->status) {
\App\Enums\TravelStatus::Draft => 'secondary',
\App\Enums\TravelStatus::Pending => 'warning',
\App\Enums\TravelStatus::Approved => 'success',
\App\Enums\TravelStatus::Rejected => 'danger',
};
@endphp
<span class="badge bg-{{ $badgeClass }}">{{ $request->status->value }}</span>
</td>
<td>{{ $request->submitted_at?->format('d M Y') ?? '—' }}</td>
<td>
<a href="{{ route('travel-requests.show', $request) }}" class="btn btn-sm btn-outline-secondary">View</a>
@if($request->status === \App\Enums\TravelStatus::Pending && auth()->user()->hasAnyRole(['travel_approver', 'administrator']))
<a href="{{ route('travel-requests.approve', $request) }}" class="btn btn-sm btn-outline-primary ms-1">Review</a>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="mt-3">
{{ $requests->links() }}
</div>
@endif
</div>

View File

@@ -0,0 +1,123 @@
<?php
use App\Models\TravelRequest;
use App\Models\TravelRequestApproval;
use App\Services\ApprovalService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
new #[Layout('components.layouts.app')] class extends Component {
public TravelRequest $travelRequest;
public ?TravelRequestApproval $pendingApproval = null;
public string $comments = '';
public function mount(int $id): void
{
$user = Auth::user();
abort_unless($user->hasAnyRole(['travel_approver', 'administrator']), 403);
$this->travelRequest = TravelRequest::with([
'user', 'journeys', 'costCodes',
'approvals.step', 'approvals.approver',
])->findOrFail($id);
$this->pendingApproval = $this->travelRequest->approvals()
->where('status', \App\Enums\ApprovalStatus::Pending->value)
->with('step')
->first();
}
public function approve(): void
{
abort_unless($this->pendingApproval, 403);
app(ApprovalService::class)->approve($this->pendingApproval, Auth::user(), $this->comments ?: null);
session()->flash('success', 'Request approved successfully.');
$this->redirect(route('dashboard'), navigate: true);
}
public function reject(): void
{
$this->validate(['comments' => ['required', 'string', 'min:5']], [
'comments.required' => 'Please provide a reason for rejection.',
'comments.min' => 'Rejection reason must be at least 5 characters.',
]);
abort_unless($this->pendingApproval, 403);
app(ApprovalService::class)->reject($this->pendingApproval, Auth::user(), $this->comments);
session()->flash('success', 'Request rejected.');
$this->redirect(route('dashboard'), navigate: true);
}
public function render(): mixed
{
return view('livewire.travel-request.approve');
}
}
?>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">Review Travel Request #{{ $travelRequest->id }}</h2>
<a href="{{ route('dashboard') }}" class="btn btn-outline-secondary btn-sm">Back</a>
</div>
@if (! $pendingApproval)
<div class="alert alert-info">This request has no pending approval steps.</div>
@else
<div class="alert alert-info">
<strong>Step {{ $pendingApproval->step->order }}: {{ $pendingApproval->step->name }}</strong>
Awaiting your review.
</div>
@endif
{{-- Request Summary --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Request Summary</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Applicant</dt>
<dd class="col-sm-9">{{ $travelRequest->user->name }} ({{ $travelRequest->user->email }})</dd>
<dt class="col-sm-3">Reason</dt>
<dd class="col-sm-9">{{ $travelRequest->reason_summary }}</dd>
<dt class="col-sm-3">Journeys</dt>
<dd class="col-sm-9">
@foreach ($travelRequest->journeys as $journey)
<div>{{ $journey->origin }} {{ $journey->destination }} on {{ $journey->date->format('d M Y') }} ({{ $journey->method->label() }})</div>
@endforeach
</dd>
<dt class="col-sm-3">Accommodation</dt>
<dd class="col-sm-9">{{ $travelRequest->needs_accommodation ? 'Required' : 'Not required' }}</dd>
<dt class="col-sm-3">Car Hire</dt>
<dd class="col-sm-9">{{ $travelRequest->needs_car_hire ? 'Required' : 'Not required' }}</dd>
<dt class="col-sm-3">Business Days</dt>
<dd class="col-sm-9">{{ $travelRequest->business_days }}</dd>
</dl>
</div>
</div>
@if ($pendingApproval)
{{-- Approve/Reject Form --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Decision</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Comments (optional for approval, required for rejection)</label>
<textarea wire:model="comments" rows="3" class="form-control @error('comments') is-invalid @enderror" placeholder="Add your comments..."></textarea>
@error('comments') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="d-flex gap-2">
<button type="button" wire:click="approve" class="btn btn-success" wire:loading.attr="disabled" wire:target="approve">
<span wire:loading wire:target="approve" class="spinner-border spinner-border-sm me-1"></span>
Approve
</button>
<button type="button" wire:click="reject" class="btn btn-danger" wire:loading.attr="disabled" wire:target="reject">
<span wire:loading wire:target="reject" class="spinner-border spinner-border-sm me-1"></span>
Reject
</button>
</div>
</div>
</div>
@endif
</div>

View File

@@ -0,0 +1,459 @@
<?php
use App\Enums\EventType;
use App\Enums\GeneralType;
use App\Enums\JourneyMethod;
use App\Enums\TravelStatus;
use App\Models\ApprovalWorkflow;
use App\Models\EmergencyContact;
use App\Models\TravelCostCode;
use App\Models\TravelJourney;
use App\Models\TravelRequest;
use App\Services\ApprovalService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
new #[Layout('components.layouts.app')] class extends Component {
// Emergency Contact
public string $emergencyFullName = '';
public string $emergencyPhone = '';
public string $emergencyRelationship = '';
// Reason for travel
public string $reasonSummary = '';
public string $travelCategory = 'event'; // 'event' or 'general'
public string $eventType = '';
public string $generalType = '';
// Journeys - array of rows
/** @var array<int, array<string, string>> */
public array $journeys = [];
// Additional details
public bool $needsAccommodation = false;
public bool $needsCarHire = false;
public bool $vehiclePolicyAcknowledged = false;
public int $businessDays = 0;
public int $privateDays = 0;
public string $additionalNotes = '';
// Cost codes - array of rows
/** @var array<int, array<string, string>> */
public array $costCodes = [];
public function mount(): void
{
$user = Auth::user();
$contact = $user->emergencyContacts()->first();
if ($contact) {
$this->emergencyFullName = $contact->full_name;
$this->emergencyPhone = $contact->phone_number;
$this->emergencyRelationship = $contact->relationship;
}
$this->addJourney();
$this->addCostCode();
}
public function addJourney(): void
{
$this->journeys[] = [
'origin' => '',
'destination' => '',
'date' => '',
'time' => '',
'method' => JourneyMethod::Air->value,
];
}
public function removeJourney(int $index): void
{
array_splice($this->journeys, $index, 1);
}
public function addCostCode(): void
{
$this->costCodes[] = [
'business_unit' => '',
'project_grant' => '',
'account_code' => '',
'class_code' => '',
];
}
public function removeCostCode(int $index): void
{
array_splice($this->costCodes, $index, 1);
}
public function saveDraft(): void
{
$this->saveRequest(submit: false);
session()->flash('success', 'Draft saved successfully.');
}
public function submit(): void
{
$this->saveRequest(submit: true);
}
private function saveRequest(bool $submit): void
{
$this->validate([
'emergencyFullName' => ['required', 'string', 'max:255'],
'emergencyPhone' => ['required', 'string', 'max:50'],
'emergencyRelationship' => ['required', 'string', 'max:100'],
'reasonSummary' => ['required', 'string', 'min:10'],
'journeys' => ['required', 'array', 'min:1'],
'journeys.*.origin' => ['required', 'string', 'max:255'],
'journeys.*.destination' => ['required', 'string', 'max:255'],
'journeys.*.date' => ['required', 'date'],
'journeys.*.method' => ['required', 'string'],
'costCodes' => ['array'],
'vehiclePolicyAcknowledged' => $this->needsCarHire ? ['accepted'] : [],
], [
'emergencyFullName.required' => 'Emergency contact name is required.',
'emergencyPhone.required' => 'Emergency contact phone is required.',
'emergencyRelationship.required' => 'Emergency contact relationship is required.',
'reasonSummary.required' => 'Please provide a reason for travel.',
'reasonSummary.min' => 'Reason must be at least 10 characters.',
'journeys.required' => 'At least one journey is required.',
'journeys.*.origin.required' => 'Origin is required for each journey.',
'journeys.*.destination.required' => 'Destination is required for each journey.',
'journeys.*.date.required' => 'Date is required for each journey.',
'vehiclePolicyAcknowledged.accepted' => 'You must acknowledge the vehicle policy when hiring a car.',
]);
$user = Auth::user();
// Save emergency contact
$user->emergencyContacts()->updateOrCreate(
['user_id' => $user->id],
[
'full_name' => $this->emergencyFullName,
'phone_number' => $this->emergencyPhone,
'relationship' => $this->emergencyRelationship,
]
);
$workflow = ApprovalWorkflow::where('is_active', true)->first();
$travelRequest = TravelRequest::create([
'user_id' => $user->id,
'workflow_id' => $workflow?->id,
'status' => TravelStatus::Draft,
'reason_summary' => $this->reasonSummary,
'event_type' => $this->travelCategory === 'event' && $this->eventType ? $this->eventType : null,
'general_type' => $this->travelCategory === 'general' && $this->generalType ? $this->generalType : null,
'needs_accommodation' => $this->needsAccommodation,
'needs_car_hire' => $this->needsCarHire,
'vehicle_policy_acknowledged' => $this->vehiclePolicyAcknowledged,
'business_days' => $this->businessDays,
'private_days' => $this->privateDays,
'additional_notes' => $this->additionalNotes ?: null,
]);
foreach ($this->journeys as $journey) {
TravelJourney::create([
'travel_request_id' => $travelRequest->id,
'origin' => $journey['origin'],
'destination' => $journey['destination'],
'date' => $journey['date'],
'time' => $journey['time'] ?: null,
'method' => $journey['method'],
]);
}
foreach ($this->costCodes as $code) {
if (array_filter($code)) {
TravelCostCode::create([
'travel_request_id' => $travelRequest->id,
'business_unit' => $code['business_unit'] ?: null,
'project_grant' => $code['project_grant'] ?: null,
'account_code' => $code['account_code'] ?: null,
'class_code' => $code['class_code'] ?: null,
]);
}
}
if ($submit) {
app(ApprovalService::class)->submit($travelRequest);
session()->flash('success', 'Travel request submitted for approval.');
}
$this->redirect(route('travel-requests.show', $travelRequest), navigate: true);
}
public function render(): mixed
{
return view('livewire.travel-request.create', [
'user' => Auth::user(),
'journeyMethods' => JourneyMethod::cases(),
'eventTypes' => EventType::cases(),
'generalTypes' => GeneralType::cases(),
]);
}
}
?>
<div>
<h2 class="h4 mb-4">New Travel Request</h2>
@if (session('success'))
<div class="alert alert-success alert-dismissible fade show">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
<form wire:submit.prevent>
{{-- Applicant Details --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Applicant Details</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<input type="text" class="form-control" value="{{ $user->name }}" readonly>
</div>
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" class="form-control" value="{{ $user->email }}" readonly>
</div>
<div class="col-md-6">
<label class="form-label">Department</label>
<input type="text" class="form-control" value="{{ $user->department ?? '—' }}" readonly>
</div>
<div class="col-md-6">
<label class="form-label">Title</label>
<input type="text" class="form-control" value="{{ $user->title ?? '—' }}" readonly>
</div>
</div>
</div>
</div>
{{-- Emergency Contact --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Emergency Contact</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Full Name <span class="text-danger">*</span></label>
<input type="text" wire:model="emergencyFullName" class="form-control @error('emergencyFullName') is-invalid @enderror">
@error('emergencyFullName') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="col-md-4">
<label class="form-label">Phone Number <span class="text-danger">*</span></label>
<input type="text" wire:model="emergencyPhone" class="form-control @error('emergencyPhone') is-invalid @enderror">
@error('emergencyPhone') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="col-md-4">
<label class="form-label">Relationship <span class="text-danger">*</span></label>
<input type="text" wire:model="emergencyRelationship" class="form-control @error('emergencyRelationship') is-invalid @enderror">
@error('emergencyRelationship') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
</div>
</div>
</div>
{{-- Reasons for Travel --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Reasons for Travel</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Reason Summary <span class="text-danger">*</span></label>
<textarea wire:model="reasonSummary" rows="3" class="form-control @error('reasonSummary') is-invalid @enderror" placeholder="Briefly describe the purpose of this travel..."></textarea>
@error('reasonSummary') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="mb-3">
<label class="form-label">Travel Category</label>
<div class="d-flex gap-4">
<div class="form-check">
<input type="radio" id="categoryEvent" wire:model.live="travelCategory" value="event" class="form-check-input">
<label for="categoryEvent" class="form-check-label">Event Specific</label>
</div>
<div class="form-check">
<input type="radio" id="categoryGeneral" wire:model.live="travelCategory" value="general" class="form-check-input">
<label for="categoryGeneral" class="form-check-label">General</label>
</div>
</div>
</div>
@if ($travelCategory === 'event')
<div class="mb-3">
<label class="form-label">Event Type</label>
<select wire:model="eventType" class="form-select">
<option value="">Select event type...</option>
@foreach ($eventTypes as $type)
<option value="{{ $type->value }}">{{ $type->label() }}</option>
@endforeach
</select>
</div>
@else
<div class="mb-3">
<label class="form-label">General Type</label>
<select wire:model="generalType" class="form-select">
<option value="">Select type...</option>
@foreach ($generalTypes as $type)
<option value="{{ $type->value }}">{{ $type->label() }}</option>
@endforeach
</select>
</div>
@endif
</div>
</div>
{{-- Journeys --}}
<div class="card mb-4">
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
Journeys
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
</div>
<div class="card-body">
@error('journeys') <div class="alert alert-danger">{{ $message }}</div> @enderror
@foreach ($journeys as $i => $journey)
<div class="border rounded p-3 mb-3 bg-light" wire:key="journey-{{ $i }}">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong class="small">Journey {{ $i + 1 }}</strong>
@if (count($journeys) > 1)
<button type="button" wire:click="removeJourney({{ $i }})" class="btn btn-sm btn-outline-danger">Remove</button>
@endif
</div>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Origin <span class="text-danger">*</span></label>
<input type="text" wire:model="journeys.{{ $i }}.origin" class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror" placeholder="e.g. Perth">
@error('journeys.'.$i.'.origin') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="col-md-3">
<label class="form-label">Destination <span class="text-danger">*</span></label>
<input type="text" wire:model="journeys.{{ $i }}.destination" class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror" placeholder="e.g. Sydney">
@error('journeys.'.$i.'.destination') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="col-md-2">
<label class="form-label">Date <span class="text-danger">*</span></label>
<input type="date" wire:model="journeys.{{ $i }}.date" class="form-control @error('journeys.'.$i.'.date') is-invalid @enderror">
@error('journeys.'.$i.'.date') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
<div class="col-md-2">
<label class="form-label">Time</label>
<input type="time" wire:model="journeys.{{ $i }}.time" class="form-control">
</div>
<div class="col-md-2">
<label class="form-label">Method</label>
<select wire:model="journeys.{{ $i }}.method" class="form-select">
@foreach ($journeyMethods as $method)
<option value="{{ $method->value }}">{{ $method->label() }}</option>
@endforeach
</select>
</div>
</div>
</div>
@endforeach
</div>
</div>
{{-- Additional Details --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Additional Details</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-auto">
<div class="form-check">
<input type="checkbox" id="needsAccommodation" wire:model="needsAccommodation" class="form-check-input">
<label for="needsAccommodation" class="form-check-label">Requires Accommodation</label>
</div>
</div>
<div class="col-auto">
<div class="form-check">
<input type="checkbox" id="needsCarHire" wire:model.live="needsCarHire" class="form-check-input">
<label for="needsCarHire" class="form-check-label">Requires Car Hire</label>
</div>
</div>
@if ($needsCarHire)
<div class="col-12">
<div class="form-check">
<input type="checkbox" id="vehiclePolicy" wire:model="vehiclePolicyAcknowledged" class="form-check-input @error('vehiclePolicyAcknowledged') is-invalid @enderror">
<label for="vehiclePolicy" class="form-check-label">I have read and acknowledge the RCSWA Vehicle Use Policy</label>
@error('vehiclePolicyAcknowledged') <div class="invalid-feedback">{{ $message }}</div> @enderror
</div>
</div>
@endif
</div>
<div class="row g-3 mb-3">
<div class="col-md-3">
<label class="form-label">Business Days</label>
<input type="number" wire:model="businessDays" class="form-control" min="0" max="365">
</div>
<div class="col-md-3">
<label class="form-label">Private Days</label>
<input type="number" wire:model="privateDays" class="form-control" min="0" max="365">
</div>
</div>
<div>
<label class="form-label">Additional Notes</label>
<textarea wire:model="additionalNotes" rows="2" class="form-control" placeholder="Any additional information..."></textarea>
</div>
</div>
</div>
{{-- Cost Codes --}}
<div class="card mb-4">
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
Cost Codes
<button type="button" wire:click="addCostCode" class="btn btn-sm btn-outline-primary">+ Add Cost Code</button>
</div>
<div class="card-body">
@foreach ($costCodes as $i => $code)
<div class="border rounded p-3 mb-3 bg-light" wire:key="code-{{ $i }}">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong class="small">Cost Code {{ $i + 1 }}</strong>
@if (count($costCodes) > 1)
<button type="button" wire:click="removeCostCode({{ $i }})" class="btn btn-sm btn-outline-danger">Remove</button>
@endif
</div>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Business Unit</label>
<input type="text" wire:model="costCodes.{{ $i }}.business_unit" class="form-control">
</div>
<div class="col-md-3">
<label class="form-label">Project/Grant</label>
<input type="text" wire:model="costCodes.{{ $i }}.project_grant" class="form-control">
</div>
<div class="col-md-3">
<label class="form-label">Account Code</label>
<input type="text" wire:model="costCodes.{{ $i }}.account_code" class="form-control">
</div>
<div class="col-md-3">
<label class="form-label">Class Code</label>
<input type="text" wire:model="costCodes.{{ $i }}.class_code" class="form-control">
</div>
</div>
</div>
@endforeach
</div>
</div>
{{-- Actions --}}
<div class="d-flex gap-2">
<button type="button" wire:click="saveDraft" class="btn btn-outline-secondary" wire:loading.attr="disabled">
<span wire:loading wire:target="saveDraft" class="spinner-border spinner-border-sm me-1"></span>
Save Draft
</button>
<button type="button" wire:click="submit" class="btn btn-primary" wire:loading.attr="disabled">
<span wire:loading wire:target="submit" class="spinner-border spinner-border-sm me-1"></span>
Submit for Approval
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,186 @@
<?php
use App\Enums\TravelStatus;
use App\Models\TravelRequest;
use Livewire\Attributes\Layout;
use Livewire\Component;
new #[Layout('components.layouts.app')] class extends Component {
public TravelRequest $travelRequest;
public function mount(int $id): void
{
$this->travelRequest = TravelRequest::with([
'user', 'workflow', 'journeys', 'costCodes',
'approvals.step', 'approvals.approver',
])->findOrFail($id);
$user = auth()->user();
abort_unless(
$user->id === $this->travelRequest->user_id || $user->hasAnyRole(['travel_approver', 'administrator']),
403
);
}
public function render(): mixed
{
return view('livewire.travel-request.show');
}
}
?>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">Travel Request #{{ $travelRequest->id }}</h2>
<div class="d-flex gap-2">
@php
$badgeClass = match($travelRequest->status) {
\App\Enums\TravelStatus::Draft => 'secondary',
\App\Enums\TravelStatus::Pending => 'warning',
\App\Enums\TravelStatus::Approved => 'success',
\App\Enums\TravelStatus::Rejected => 'danger',
};
@endphp
<span class="badge bg-{{ $badgeClass }} fs-6">{{ $travelRequest->status->value }}</span>
@if ($travelRequest->status === \App\Enums\TravelStatus::Pending && auth()->user()->hasAnyRole(['travel_approver', 'administrator']))
<a href="{{ route('travel-requests.approve', $travelRequest) }}" class="btn btn-primary btn-sm">Review</a>
@endif
<a href="{{ route('dashboard') }}" class="btn btn-outline-secondary btn-sm">Back</a>
</div>
</div>
{{-- Applicant Details --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Applicant Details</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<dl class="row mb-0">
<dt class="col-sm-4">Name</dt>
<dd class="col-sm-8">{{ $travelRequest->user->name }}</dd>
<dt class="col-sm-4">Email</dt>
<dd class="col-sm-8">{{ $travelRequest->user->email }}</dd>
<dt class="col-sm-4">Department</dt>
<dd class="col-sm-8">{{ $travelRequest->user->department ?? '—' }}</dd>
</dl>
</div>
<div class="col-md-6">
<dl class="row mb-0">
<dt class="col-sm-4">Submitted</dt>
<dd class="col-sm-8">{{ $travelRequest->submitted_at?->format('d M Y H:i') ?? 'Not submitted' }}</dd>
<dt class="col-sm-4">Workflow</dt>
<dd class="col-sm-8">{{ $travelRequest->workflow?->name ?? '—' }}</dd>
</dl>
</div>
</div>
</div>
</div>
{{-- Reason --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Reason for Travel</div>
<div class="card-body">
<p class="mb-2">{{ $travelRequest->reason_summary }}</p>
@if ($travelRequest->event_type)
<span class="badge bg-info text-dark">Event: {{ $travelRequest->event_type->label() }}</span>
@elseif ($travelRequest->general_type)
<span class="badge bg-info text-dark">General: {{ $travelRequest->general_type->label() }}</span>
@endif
</div>
</div>
{{-- Journeys --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Journeys</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Origin</th>
<th>Destination</th>
<th>Date</th>
<th>Time</th>
<th>Method</th>
</tr>
</thead>
<tbody>
@foreach ($travelRequest->journeys as $journey)
<tr>
<td>{{ $journey->origin }}</td>
<td>{{ $journey->destination }}</td>
<td>{{ $journey->date->format('d M Y') }}</td>
<td>{{ $journey->time ?? '—' }}</td>
<td>{{ $journey->method->label() }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- Additional Details --}}
<div class="card mb-4">
<div class="card-header fw-semibold">Additional Details</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-auto">
<span class="badge {{ $travelRequest->needs_accommodation ? 'bg-primary' : 'bg-secondary' }}">
Accommodation: {{ $travelRequest->needs_accommodation ? 'Required' : 'Not Required' }}
</span>
</div>
<div class="col-auto">
<span class="badge {{ $travelRequest->needs_car_hire ? 'bg-primary' : 'bg-secondary' }}">
Car Hire: {{ $travelRequest->needs_car_hire ? 'Required' : 'Not Required' }}
</span>
</div>
</div>
<dl class="row mb-0">
<dt class="col-sm-3">Business Days</dt>
<dd class="col-sm-9">{{ $travelRequest->business_days }}</dd>
<dt class="col-sm-3">Private Days</dt>
<dd class="col-sm-9">{{ $travelRequest->private_days }}</dd>
@if ($travelRequest->additional_notes)
<dt class="col-sm-3">Notes</dt>
<dd class="col-sm-9">{{ $travelRequest->additional_notes }}</dd>
@endif
</dl>
</div>
</div>
{{-- Approval Timeline --}}
@if ($travelRequest->approvals->isNotEmpty())
<div class="card mb-4">
<div class="card-header fw-semibold">Approval Timeline</div>
<div class="card-body">
@foreach ($travelRequest->approvals as $approval)
<div class="d-flex align-items-start mb-3">
<div class="me-3 mt-1">
@php
$iconClass = match($approval->status) {
\App\Enums\ApprovalStatus::Approved => 'text-success',
\App\Enums\ApprovalStatus::Rejected => 'text-danger',
default => 'text-warning',
};
@endphp
<span class="badge bg-{{ $iconClass === 'text-success' ? 'success' : ($iconClass === 'text-danger' ? 'danger' : 'warning') }}">
Step {{ $approval->step->order }}
</span>
</div>
<div>
<strong>{{ $approval->step->name }}</strong>
<span class="ms-2 badge bg-{{ $approval->status === \App\Enums\ApprovalStatus::Approved ? 'success' : ($approval->status === \App\Enums\ApprovalStatus::Rejected ? 'danger' : 'warning') }}">
{{ $approval->status->value }}
</span>
@if ($approval->approver)
<div class="text-muted small">By {{ $approval->approver->name }} on {{ $approval->acted_at?->format('d M Y H:i') }}</div>
@endif
@if ($approval->comments)
<div class="mt-1 text-muted small fst-italic">"{{ $approval->comments }}"</div>
@endif
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>