543 lines
26 KiB
PHP
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>
|