Files
travel/resources/views/livewire/travel-request/create.blade.php
timmybee 7b26fc57f4
All checks were successful
linter / quality (push) Successful in 1m22s
security / Dependency Audit (push) Successful in 1m27s
security / Static Analysis (push) Successful in 1m24s
tests / ci (8.4) (push) Successful in 1m52s
tests / ci (8.5) (push) Successful in 2m10s
Merge pull request 'Fix form submission staying in draft status' (#13) from fix/submit-draft-status into master
Reviewed-on: #13
2026-03-06 12:29:00 +08:00

543 lines
26 KiB
PHP

<?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);
}
public function submit(): void
{
$this->saveRequest(submit: true);
}
private function saveRequest(bool $submit): void
{
if ($submit && ! ApprovalWorkflow::where('is_active', true)->exists()) {
$this->addError('workflow', 'No active approval workflow is configured. Please contact an administrator.');
return;
}
$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.');
} else {
session()->flash('success', 'Draft saved successfully.');
}
$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
@error('workflow')
<div class="alert alert-danger">{{ $message }}</div>
@enderror
<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" x-data="{
stateFilter: '',
suggestions: {},
open: {},
async searchTowns(key, query) {
if (query.length < 2) {
this.suggestions[key] = [];
this.open[key] = false;
return;
}
const params = new URLSearchParams({ query });
if (this.stateFilter) params.append('state', this.stateFilter);
const res = await fetch(`/towns/search?${params}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
this.suggestions[key] = data;
this.open[key] = data.length > 0;
},
selectTown(key, wirePath, name) {
$wire.set(wirePath, name);
this.suggestions[key] = [];
this.open[key] = false;
}
}">
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
<span>Journeys</span>
<div class="d-flex align-items-center gap-2">
<select x-model="stateFilter" class="form-select form-select-sm" style="width: auto;" title="Filter town autocomplete by state">
<option value="">All states</option>
@foreach(\App\Enums\AustralianState::cases() as $state)
<option value="{{ $state->value }}">{{ $state->abbreviation() }}</option>
@endforeach
</select>
<button type="button" wire:click="addJourney" class="btn btn-sm btn-outline-primary">+ Add Journey</button>
</div>
</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" 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>
<div class="position-relative">
<input
type="text"
wire:model="journeys.{{ $i }}.origin"
class="form-control @error('journeys.'.$i.'.origin') is-invalid @enderror"
placeholder="e.g. Perth"
autocomplete="off"
x-on:input.debounce.300ms="searchTowns('{{ $i }}-origin', $event.target.value)"
x-on:blur="$nextTick(() => open['{{ $i }}-origin'] = false)"
>
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-origin'] ?? false" style="z-index: 1055;">
<template x-for="s in (suggestions['{{ $i }}-origin'] ?? [])">
<li>
<button type="button" class="dropdown-item small"
x-text="s.label"
x-on:mousedown.prevent="selectTown('{{ $i }}-origin', 'journeys.{{ $i }}.origin', s.name)"
></button>
</li>
</template>
</ul>
</div>
@error('journeys.'.$i.'.origin') <div class="invalid-feedback d-block">{{ $message }}</div> @enderror
</div>
<div class="col-md-3">
<label class="form-label">Destination <span class="text-danger">*</span></label>
<div class="position-relative">
<input
type="text"
wire:model="journeys.{{ $i }}.destination"
class="form-control @error('journeys.'.$i.'.destination') is-invalid @enderror"
placeholder="e.g. Sydney"
autocomplete="off"
x-on:input.debounce.300ms="searchTowns('{{ $i }}-destination', $event.target.value)"
x-on:blur="$nextTick(() => open['{{ $i }}-destination'] = false)"
>
<ul class="dropdown-menu w-100" x-show="open['{{ $i }}-destination'] ?? false" style="z-index: 1055;">
<template x-for="s in (suggestions['{{ $i }}-destination'] ?? [])">
<li>
<button type="button" class="dropdown-item small"
x-text="s.label"
x-on:mousedown.prevent="selectTown('{{ $i }}-destination', 'journeys.{{ $i }}.destination', s.name)"
></button>
</li>
</template>
</ul>
</div>
@error('journeys.'.$i.'.destination') <div class="invalid-feedback d-block">{{ $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" 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>