initial
This commit is contained in:
1
resources/css/app.css
Normal file
1
resources/css/app.css
Normal file
@@ -0,0 +1 @@
|
||||
@import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
4
resources/js/app.js
Normal file
4
resources/js/app.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import * as bootstrap from 'bootstrap';
|
||||
import './../../node_modules/bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||
|
||||
window.bootstrap = bootstrap;
|
||||
96
resources/views/components/layouts/app.blade.php
Normal file
96
resources/views/components/layouts/app.blade.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" x-data x-init="
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved) { document.documentElement.setAttribute('data-bs-theme', saved); }
|
||||
">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ config('app.name', 'Travel Requests') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="{{ route('dashboard') }}">Travel Requests</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ route('dashboard') }}">Dashboard</a>
|
||||
</li>
|
||||
@auth
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ route('travel-requests.create') }}">New Request</a>
|
||||
</li>
|
||||
@if(auth()->user()->hasAnyRole(['travel_approver', 'administrator']))
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ route('dashboard') }}">Pending Approvals</a>
|
||||
</li>
|
||||
@endif
|
||||
@if(auth()->user()->hasRole('administrator'))
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url('/admin') }}">Admin Panel</a>
|
||||
</li>
|
||||
@endif
|
||||
@endauth
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto align-items-center">
|
||||
<li class="nav-item me-2">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-light"
|
||||
x-on:click="
|
||||
const current = document.documentElement.getAttribute('data-bs-theme');
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-bs-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
"
|
||||
title="Toggle dark/light mode"
|
||||
>
|
||||
<span x-show="document.documentElement.getAttribute('data-bs-theme') !== 'dark'">🌙</span>
|
||||
<span x-show="document.documentElement.getAttribute('data-bs-theme') === 'dark'">☀️</span>
|
||||
</button>
|
||||
</li>
|
||||
@auth
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown">
|
||||
{{ auth()->user()->name }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><span class="dropdown-item-text small text-muted">{{ auth()->user()->email }}</span></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="dropdown-item">Sign Out</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@else
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ route('login') }}">Sign In</a>
|
||||
</li>
|
||||
@endauth
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container py-4">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
<footer class="border-top py-3 mt-auto">
|
||||
<div class="container text-center text-muted small">
|
||||
© {{ date('Y') }} Travel Request System
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
15
resources/views/components/layouts/guest.blade.php
Normal file
15
resources/views/components/layouts/guest.blade.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ config('app.name', 'Travel Requests') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
{{ $slot }}
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
95
resources/views/livewire/auth/login.blade.php
Normal file
95
resources/views/livewire/auth/login.blade.php
Normal 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>
|
||||
94
resources/views/livewire/dashboard.blade.php
Normal file
94
resources/views/livewire/dashboard.blade.php
Normal 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>
|
||||
123
resources/views/livewire/travel-request/approve.blade.php
Normal file
123
resources/views/livewire/travel-request/approve.blade.php
Normal 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>
|
||||
459
resources/views/livewire/travel-request/create.blade.php
Normal file
459
resources/views/livewire/travel-request/create.blade.php
Normal 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>
|
||||
186
resources/views/livewire/travel-request/show.blade.php
Normal file
186
resources/views/livewire/travel-request/show.blade.php
Normal 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>
|
||||
17
resources/views/mail/approval-decision.blade.php
Normal file
17
resources/views/mail/approval-decision.blade.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<p>Dear {{ $travelRequest->user->name }},</p>
|
||||
|
||||
<p>Your travel request #{{ $travelRequest->id }} has been <strong>{{ $travelRequest->status->value }}</strong>.</p>
|
||||
|
||||
@php
|
||||
$lastApproval = $travelRequest->approvals()->with('approver')->latest()->first();
|
||||
@endphp
|
||||
|
||||
@if ($lastApproval?->comments)
|
||||
<p><strong>Comments:</strong> {{ $lastApproval->comments }}</p>
|
||||
@endif
|
||||
|
||||
<p>
|
||||
<a href="{{ route('travel-requests.show', $travelRequest) }}">View your request</a>
|
||||
</p>
|
||||
|
||||
<p>Travel Request System</p>
|
||||
18
resources/views/mail/approval-request.blade.php
Normal file
18
resources/views/mail/approval-request.blade.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<p>Dear {{ $approver->name }},</p>
|
||||
|
||||
<p>A travel request has been submitted and requires your approval.</p>
|
||||
|
||||
<p>
|
||||
<strong>Request #:</strong> {{ $travelRequest->id }}<br>
|
||||
<strong>Submitted by:</strong> {{ $travelRequest->user->name }} ({{ $travelRequest->user->email }})<br>
|
||||
<strong>Reason:</strong> {{ $travelRequest->reason_summary }}<br>
|
||||
<strong>Submitted:</strong> {{ $travelRequest->submitted_at?->format('d M Y H:i') }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ $signedUrl }}">Click here to review and approve or reject this request</a>
|
||||
</p>
|
||||
|
||||
<p>This link is signed and will expire. Please action it promptly.</p>
|
||||
|
||||
<p>Travel Request System</p>
|
||||
252
resources/views/welcome.blade.php
Normal file
252
resources/views/welcome.blade.php
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user