Laravel + Inertia + Vue 3 - Real PBS Code Examples
Document Version: 1.0 Last Updated: December 12, 2025
This document provides practical, copy-paste code examples for building PBS Planning with Laravel + Inertia + Vue 3.
Table of Contents
- Project Setup
- Authentication
- Project List Page
- Create Project Form
- Multi-Tenancy
- Crew Portal Example
- Subdomain Routing
- Component Patterns
1. Project Setup
Installation Commands
# Create Laravel project
composer create-project laravel/laravel pbs-planning
cd pbs-planning
# Install Breeze with Inertia + Vue
composer require laravel/breeze --dev
php artisan breeze:install vue
# Install additional packages
npm install pinia vee-validate @vee-validate/zod zod @heroicons/vue
# Install component library (choose one)
npm install primevue # Recommended for PBS
# Install multi-tenancy
composer require stancl/tenancy
# Setup database
php artisan migrate
Configure Pinia (State Management)
// resources/js/app.js
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import { createPinia } from 'pinia'
const pinia = createPinia()
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true })
return pages[`./Pages/${name}.vue`]
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(pinia) // Add Pinia
.mount(el)
},
})
2. Authentication
Login Page (Vue Component)
<!-- resources/js/Pages/Auth/Login.vue -->
<script setup>
import { useForm } from '@inertiajs/vue3';
import { Head, Link } from '@inertiajs/vue3';
const form = useForm({
email: '',
password: '',
remember: false
});
const submit = () => {
form.post('/login', {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<Head title="Login" />
<div class="min-h-screen flex items-center justify-center bg-gray-100">
<div class="max-w-md w-full bg-white p-8 rounded-lg shadow">
<h2 class="text-2xl font-bold mb-6">PBS Planning Login</h2>
<form @submit.prevent="submit" class="space-y-6">
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm
focus:border-blue-500 focus:ring-blue-500"
/>
<div v-if="form.errors.email" class="text-red-600 text-sm mt-1">
{{ form.errors.email }}
</div>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm
focus:border-blue-500 focus:ring-blue-500"
/>
<div v-if="form.errors.password" class="text-red-600 text-sm mt-1">
{{ form.errors.password }}
</div>
</div>
<!-- Remember Me -->
<div class="flex items-center">
<input
id="remember"
v-model="form.remember"
type="checkbox"
class="rounded border-gray-300 text-blue-600"
/>
<label for="remember" class="ml-2 text-sm text-gray-700">
Remember me
</label>
</div>
<!-- Submit Button -->
<button
type="submit"
:disabled="form.processing"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md
hover:bg-blue-700 disabled:opacity-50"
>
{{ form.processing ? 'Logging in...' : 'Login' }}
</button>
</form>
</div>
</div>
</template>
Laravel Authentication Controller
<?php
// app/Http/Controllers/Auth/LoginController.php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class LoginController extends Controller
{
public function create()
{
return Inertia::render('Auth/Login');
}
public function store(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate();
// Redirect based on user role
$user = Auth::user();
if ($user->hasRole('admin')) {
return redirect()->intended('/backend/dashboard');
}
return redirect()->intended('/crew/dashboard');
}
return back()->withErrors([
'email' => 'The provided credentials do not match our records.',
]);
}
}
3. Project List Page (Real PBS Example)
Laravel Controller
<?php
// app/Http/Controllers/Backend/ProjectController.php
namespace App\Http\Controllers\Backend;
use App\Http\Controllers\Controller;
use App\Models\Project;
use Inertia\Inertia;
class ProjectController extends Controller
{
public function index()
{
$projects = Project::query()
->with(['assignments.crewProfile', 'company'])
->forCurrentCompany() // Multi-tenant scope
->latest()
->get()
->map(function ($project) {
return [
'id' => $project->id,
'name' => $project->name,
'status' => $project->status,
'start_date' => $project->start_date->format('Y-m-d'),
'end_date' => $project->end_date->format('Y-m-d'),
'location' => $project->location,
'budget' => $project->budget_amount,
'crew_count' => $project->assignments->count(),
'crew_confirmed' => $project->assignments()
->where('status', 'confirmed')->count(),
'crew_avatars' => $project->assignments->take(3)->map(fn($a) => [
'initials' => $a->crewProfile->initials,
'name' => $a->crewProfile->full_name,
]),
];
});
$stats = [
'total_crew' => auth()->user()->company->crewProfiles()->count(),
'confirmed_crew' => auth()->user()->company->crewProfiles()
->where('status', 'confirmed')->count(),
'ongoing_projects' => Project::forCurrentCompany()
->where('status', 'active')->count(),
'pending_projects' => Project::forCurrentCompany()
->where('status', 'planning')->count(),
];
return Inertia::render('Backend/Projects/Index', [
'projects' => $projects,
'stats' => $stats,
]);
}
}
Vue Component
<!-- resources/js/Pages/Backend/Projects/Index.vue -->
<script setup>
import { ref, computed } from 'vue';
import { Head, Link, router } from '@inertiajs/vue3';
import BackendLayout from '@/Layouts/BackendLayout.vue';
import ProjectCard from '@/Components/Backend/ProjectCard.vue';
const props = defineProps({
projects: Array,
stats: Object
});
const searchQuery = ref('');
const viewMode = ref('cards'); // 'cards' or 'list'
const filteredProjects = computed(() => {
if (!searchQuery.value) return props.projects;
return props.projects.filter(project =>
project.name.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const goToProject = (projectId) => {
router.visit(`/projects/${projectId}`);
};
</script>
<template>
<Head title="Projects" />
<BackendLayout>
<!-- Header -->
<div class="bg-white p-6 border-b">
<div class="flex justify-between items-center mb-4">
<div>
<h1 class="text-2xl font-bold">Hej {{ $page.props.auth.user.name }}!</h1>
<p class="text-gray-600 text-sm mt-1">Dags att planera igen</p>
</div>
<div class="flex gap-3">
<!-- View Toggle -->
<div class="flex bg-gray-100 rounded-lg p-1">
<button
@click="viewMode = 'cards'"
:class="[
'px-4 py-2 rounded-md text-sm font-medium transition',
viewMode === 'cards'
? 'bg-white shadow'
: 'text-gray-600 hover:text-gray-900'
]"
>
Cards
</button>
<button
@click="viewMode = 'list'"
:class="[
'px-4 py-2 rounded-md text-sm font-medium transition',
viewMode === 'list'
? 'bg-white shadow'
: 'text-gray-600 hover:text-gray-900'
]"
>
List
</button>
</div>
<!-- New Project Button -->
<Link
href="/projects/create"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<span>+</span>
<span>New Project</span>
</Link>
</div>
</div>
<!-- Search Bar -->
<input
v-model="searchQuery"
type="text"
placeholder="Search projects..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<!-- Stats Summary -->
<div class="p-6 bg-gray-50">
<div class="grid grid-cols-4 gap-4">
<div class="bg-white p-5 rounded-lg border">
<div class="text-3xl font-bold">{{ stats.total_crew }}</div>
<div class="text-sm text-gray-600 mt-1">Total crew in crewpool</div>
<div class="text-xs text-gray-500 mt-1">
{{ stats.confirmed_crew }} confirmed, {{ stats.total_crew - stats.confirmed_crew }} pending
</div>
</div>
<div class="bg-white p-5 rounded-lg border">
<div class="text-3xl font-bold">{{ stats.ongoing_projects }}</div>
<div class="text-sm text-gray-600 mt-1">Total Ongoing projects</div>
</div>
<div class="bg-white p-5 rounded-lg border">
<div class="text-3xl font-bold">{{ stats.pending_projects }}</div>
<div class="text-sm text-gray-600 mt-1">Total Pending projects</div>
</div>
<div class="bg-white p-5 rounded-lg border">
<div class="text-3xl font-bold">47</div>
<div class="text-sm text-gray-600 mt-1">Total archived projects</div>
</div>
</div>
</div>
<!-- Projects Grid/List -->
<div class="p-6">
<h2 class="text-xl font-semibold mb-5">Recent projects</h2>
<!-- Cards View -->
<div
v-if="viewMode === 'cards'"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"
>
<ProjectCard
v-for="project in filteredProjects"
:key="project.id"
:project="project"
@click="goToProject(project.id)"
/>
</div>
<!-- List View -->
<div v-else class="space-y-3">
<div
v-for="project in filteredProjects"
:key="project.id"
@click="goToProject(project.id)"
class="bg-white p-5 rounded-lg border hover:border-blue-500 cursor-pointer transition flex items-center justify-between"
>
<div class="flex items-center gap-6 flex-1">
<div>
<div class="font-semibold">{{ project.name }}</div>
<div class="text-sm text-gray-600">
📅 {{ project.start_date }} - {{ project.end_date }}
</div>
</div>
<div class="flex items-center gap-8">
<div class="text-sm">
<span class="text-gray-600">👥 Crew:</span>
<span class="font-semibold ml-2">
{{ project.crew_confirmed }}/{{ project.crew_count }}
</span>
</div>
<div class="text-sm">
<span class="text-gray-600">💰 Budget:</span>
<span class="font-semibold ml-2">{{ project.budget }} SEK</span>
</div>
</div>
</div>
<button class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
Go to project
</button>
</div>
</div>
<!-- Empty State -->
<div
v-if="filteredProjects.length === 0"
class="text-center py-12 text-gray-500"
>
<p>No projects found.</p>
</div>
</div>
</BackendLayout>
</template>
Project Card Component
<!-- resources/js/Components/Backend/ProjectCard.vue -->
<script setup>
import { computed } from 'vue';
const props = defineProps({
project: Object
});
const statusClass = computed(() => {
const classes = {
active: 'bg-green-100 text-green-800',
planning: 'bg-blue-100 text-blue-800',
completed: 'bg-gray-100 text-gray-600'
};
return classes[props.project.status] || '';
});
const progressPercentage = computed(() => {
if (props.project.crew_count === 0) return 0;
return (props.project.crew_confirmed / props.project.crew_count) * 100;
});
</script>
<template>
<div class="bg-white p-5 rounded-lg border hover:border-blue-500 cursor-pointer transition hover:shadow-lg">
<!-- Header -->
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<h3 class="font-semibold text-lg mb-1">{{ project.name }}</h3>
<p class="text-sm text-gray-600">
📅 {{ project.start_date }} - {{ project.end_date }}
</p>
</div>
<span
:class="['px-3 py-1 rounded-full text-xs font-semibold', statusClass]"
>
{{ project.status.toUpperCase() }}
</span>
</div>
<!-- Meta Information -->
<div class="grid grid-cols-2 gap-3 mb-3 pb-3 border-b">
<div>
<div class="text-xs text-gray-500 uppercase font-semibold">Crew</div>
<div class="font-semibold">{{ project.crew_count }} members</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase font-semibold">Budget</div>
<div class="font-semibold">~{{ project.budget }}K SEK</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase font-semibold">Location</div>
<div class="font-semibold">{{ project.location }}</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase font-semibold">Unassigned</div>
<div :class="['font-semibold', project.crew_count - project.crew_confirmed > 0 ? 'text-red-600' : '']">
{{ project.crew_count - project.crew_confirmed }} posts
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="mb-3">
<div class="flex justify-between text-sm mb-2">
<span>Crew Confirmed</span>
<span>{{ project.crew_confirmed }}/{{ project.crew_count }}</span>
</div>
<div class="bg-gray-200 rounded-full h-1.5">
<div
class="bg-green-500 h-1.5 rounded-full transition-all"
:style="{ width: `${progressPercentage}%` }"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between pt-3 border-t">
<!-- Crew Avatars -->
<div class="flex items-center">
<div
v-for="(crew, index) in project.crew_avatars"
:key="index"
:title="crew.name"
class="w-7 h-7 rounded-full bg-blue-600 text-white text-xs font-semibold flex items-center justify-center border-2 border-white -ml-2 first:ml-0"
>
{{ crew.initials }}
</div>
<span v-if="project.crew_count > 3" class="text-sm text-gray-600 ml-2">
+{{ project.crew_count - 3 }} more
</span>
</div>
<!-- Action Button -->
<button
@click.stop="$emit('click')"
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700"
>
Go to Project →
</button>
</div>
</div>
</template>
4. Create Project Form
Laravel Controller
<?php
// app/Http/Controllers/Backend/ProjectController.php
public function create()
{
return Inertia::render('Backend/Projects/Create', [
'locations' => Location::all(),
'roles' => Function::global()->get(), // Global functions
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|max:255',
'location' => 'required',
'start_date' => 'required|date',
'end_date' => 'required|date|after:start_date',
'budget_amount' => 'required|numeric|min:0',
'description' => 'nullable|string',
]);
$project = $request->user()->company->projects()->create($validated);
return redirect()
->route('projects.show', $project)
->with('success', 'Project created successfully!');
}
Vue Component with VeeValidate
<!-- resources/js/Pages/Backend/Projects/Create.vue -->
<script setup>
import { useForm } from '@inertiajs/vue3';
import { Head } from '@inertiajs/vue3';
import BackendLayout from '@/Layouts/BackendLayout.vue';
const props = defineProps({
locations: Array,
roles: Array
});
const form = useForm({
name: '',
location: '',
start_date: '',
end_date: '',
budget_amount: 0,
description: ''
});
const submit = () => {
form.post('/projects');
};
</script>
<template>
<Head title="Create Project" />
<BackendLayout>
<div class="max-w-4xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Create New Project</h1>
<form @submit.prevent="submit" class="bg-white p-6 rounded-lg shadow space-y-6">
<!-- Project Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
Project Name *
</label>
<input
id="name"
v-model="form.name"
type="text"
required
placeholder="Champions League Final 2025"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<div v-if="form.errors.name" class="text-red-600 text-sm mt-1">
{{ form.errors.name }}
</div>
</div>
<!-- Location -->
<div>
<label for="location" class="block text-sm font-medium text-gray-700 mb-1">
Location *
</label>
<input
id="location"
v-model="form.location"
type="text"
required
placeholder="Stockholm, Sweden"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<div v-if="form.errors.location" class="text-red-600 text-sm mt-1">
{{ form.errors.location }}
</div>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 mb-1">
Start Date *
</label>
<input
id="start_date"
v-model="form.start_date"
type="date"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<div v-if="form.errors.start_date" class="text-red-600 text-sm mt-1">
{{ form.errors.start_date }}
</div>
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 mb-1">
End Date *
</label>
<input
id="end_date"
v-model="form.end_date"
type="date"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<div v-if="form.errors.end_date" class="text-red-600 text-sm mt-1">
{{ form.errors.end_date }}
</div>
</div>
</div>
<!-- Budget -->
<div>
<label for="budget" class="block text-sm font-medium text-gray-700 mb-1">
Budget Amount (SEK) *
</label>
<input
id="budget"
v-model.number="form.budget_amount"
type="number"
required
min="0"
step="1000"
placeholder="500000"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<div v-if="form.errors.budget_amount" class="text-red-600 text-sm mt-1">
{{ form.errors.budget_amount }}
</div>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
v-model="form.description"
rows="4"
placeholder="Project details, special requirements, etc..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<div v-if="form.errors.description" class="text-red-600 text-sm mt-1">
{{ form.errors.description }}
</div>
</div>
<!-- Submit Buttons -->
<div class="flex gap-3 pt-4">
<button
type="submit"
:disabled="form.processing"
class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{{ form.processing ? 'Creating...' : 'Create Project' }}
</button>
<button
type="button"
@click="$inertia.visit('/projects')"
class="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
</BackendLayout>
</template>
5. Multi-Tenancy Setup
Company Model with Scope
<?php
// app/Models/Company.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Company extends Model
{
protected $fillable = [
'name',
'subdomain',
'brand_color',
'logo_url',
];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function projects(): HasMany
{
return $this->hasMany(Project::class);
}
public function crewProfiles(): HasMany
{
return $this->hasMany(CrewProfile::class);
}
}
Project Model with Company Scope
<?php
// app/Models/Project.php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Project extends Model
{
protected $fillable = [
'company_id',
'name',
'location',
'start_date',
'end_date',
'budget_amount',
'status',
'description'
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
];
// Relationships
public function company()
{
return $this->belongsTo(Company::class);
}
public function assignments()
{
return $this->hasMany(Assignment::class);
}
// Multi-tenant scope
public function scopeForCurrentCompany(Builder $query)
{
return $query->where('company_id', auth()->user()->company_id);
}
}
6. Crew Portal - Offer Card
Laravel Controller
<?php
// app/Http/Controllers/CrewPortal/OfferController.php
namespace App\Http\Controllers\CrewPortal;
use App\Http\Controllers\Controller;
use App\Models\AssignmentOffer;
use Inertia\Inertia;
class OfferController extends Controller
{
public function index()
{
$crewProfile = auth()->user()->crewProfile;
$offers = AssignmentOffer::query()
->where('crew_profile_id', $crewProfile->id)
->with(['assignment.project.company'])
->latest()
->get()
->map(function ($offer) {
return [
'id' => $offer->id,
'project_name' => $offer->assignment->project->name,
'company_name' => $offer->assignment->project->company->name,
'role' => $offer->assignment->function_name,
'start_date' => $offer->work_start_date->format('Y-m-d'),
'end_date' => $offer->work_end_date->format('Y-m-d'),
'work_days' => $offer->work_days_count,
'work_rate' => $offer->work_rate_amount,
'travel_days' => $offer->travel_days_count,
'travel_rate' => $offer->travel_rate_amount,
'total' => $offer->total_amount,
'currency' => $offer->currency_code,
'status' => $offer->status,
'expires_at' => $offer->expires_at?->format('Y-m-d H:i'),
'message' => $offer->message,
];
});
return Inertia::render('CrewPortal/Offers/Index', [
'offers' => $offers
]);
}
public function accept($id)
{
$offer = AssignmentOffer::findOrFail($id);
// Validate ownership
if ($offer->crew_profile_id !== auth()->user()->crew_profile_id) {
abort(403);
}
$offer->update(['status' => 'accepted']);
return redirect()
->back()
->with('success', 'Offer accepted! Waiting for confirmation.');
}
public function decline($id)
{
$offer = AssignmentOffer::findOrFail($id);
if ($offer->crew_profile_id !== auth()->user()->crew_profile_id) {
abort(403);
}
$offer->update(['status' => 'declined']);
return redirect()
->back()
->with('success', 'Offer declined.');
}
}
Vue Component (Mobile-First)
<!-- resources/js/Pages/CrewPortal/Offers/Index.vue -->
<script setup>
import { Head } from '@inertiajs/vue3';
import { router } from '@inertiajs/vue3';
import CrewLayout from '@/Layouts/CrewLayout.vue';
import OfferCard from '@/Components/CrewPortal/OfferCard.vue';
const props = defineProps({
offers: Array
});
const acceptOffer = (offerId) => {
if (confirm('Accept this offer?')) {
router.post(`/offers/${offerId}/accept`);
}
};
const declineOffer = (offerId) => {
if (confirm('Decline this offer?')) {
router.post(`/offers/${offerId}/decline`);
}
};
</script>
<template>
<Head title="My Offers" />
<CrewLayout>
<div class="p-4 md:p-6 max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-6">My Offers</h1>
<!-- Offers List -->
<div class="space-y-4">
<OfferCard
v-for="offer in offers"
:key="offer.id"
:offer="offer"
@accept="acceptOffer(offer.id)"
@decline="declineOffer(offer.id)"
/>
</div>
<!-- Empty State -->
<div v-if="offers.length === 0" class="text-center py-12">
<p class="text-gray-500">No pending offers.</p>
</div>
</div>
</CrewLayout>
</template>
Offer Card Component
<!-- resources/js/Components/CrewPortal/OfferCard.vue -->
<script setup>
import { computed } from 'vue';
const props = defineProps({
offer: Object
});
const emit = defineEmits(['accept', 'decline']);
const statusColor = computed(() => {
const colors = {
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
declined: 'bg-red-100 text-red-600',
expired: 'bg-gray-100 text-gray-600'
};
return colors[props.offer.status] || '';
});
const isPending = computed(() => props.offer.status === 'pending');
</script>
<template>
<div class="bg-white rounded-lg shadow border p-4 md:p-6">
<!-- Header -->
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="font-semibold text-lg">{{ offer.project_name }}</h3>
<p class="text-sm text-gray-600">{{ offer.company_name }}</p>
</div>
<span :class="['px-3 py-1 rounded-full text-xs font-semibold', statusColor]">
{{ offer.status.toUpperCase() }}
</span>
</div>
<!-- Role & Dates -->
<div class="mb-4">
<div class="font-medium">{{ offer.role }}</div>
<div class="text-sm text-gray-600">
📅 {{ offer.start_date }} - {{ offer.end_date }}
</div>
</div>
<!-- Rate Breakdown -->
<div class="bg-gray-50 p-4 rounded-lg mb-4">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span>{{ offer.work_days }} work days @ {{ offer.work_rate }} {{ offer.currency }}</span>
<span class="font-semibold">
{{ offer.work_days * offer.work_rate }} {{ offer.currency }}
</span>
</div>
<div v-if="offer.travel_days > 0" class="flex justify-between">
<span>{{ offer.travel_days }} travel days @ {{ offer.travel_rate }} {{ offer.currency }}</span>
<span class="font-semibold">
{{ offer.travel_days * offer.travel_rate }} {{ offer.currency }}
</span>
</div>
<div class="flex justify-between pt-2 border-t font-bold text-base">
<span>Total</span>
<span>{{ offer.total }} {{ offer.currency }}</span>
</div>
</div>
</div>
<!-- Message -->
<div v-if="offer.message" class="mb-4 p-3 bg-blue-50 rounded border-l-4 border-blue-500">
<p class="text-sm">{{ offer.message }}</p>
</div>
<!-- Expiration Warning -->
<div v-if="offer.expires_at && isPending" class="mb-4 text-sm text-orange-600">
⏰ Expires: {{ offer.expires_at }}
</div>
<!-- Actions -->
<div v-if="isPending" class="flex gap-3">
<button
@click="$emit('accept')"
class="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-green-700 active:bg-green-800 transition"
>
✓ Accept Offer
</button>
<button
@click="$emit('decline')"
class="flex-1 bg-gray-200 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-300 active:bg-gray-400 transition"
>
✗ Decline
</button>
</div>
</div>
</template>
7. Subdomain Routing
Routes Configuration
<?php
// routes/web.php
use App\Http\Controllers\Backend;
use App\Http\Controllers\CrewPortal;
use Illuminate\Support\Facades\Route;
// Backend Portal (admin users)
Route::domain('backend.' . config('app.domain'))
->middleware(['auth', 'role:admin'])
->name('backend.')
->group(function () {
Route::get('/dashboard', [Backend\DashboardController::class, 'index'])
->name('dashboard');
Route::resource('projects', Backend\ProjectController::class);
Route::resource('crew', Backend\CrewController::class);
Route::get('/offers', [Backend\OfferController::class, 'index'])->name('offers.index');
Route::post('/offers', [Backend\OfferController::class, 'store'])->name('offers.store');
});
// Crew Portal (crew users)
Route::domain('crew.' . config('app.domain'))
->middleware(['auth', 'role:crew'])
->name('crew.')
->group(function () {
Route::get('/dashboard', [CrewPortal\DashboardController::class, 'index'])
->name('dashboard');
Route::get('/offers', [CrewPortal\OfferController::class, 'index'])
->name('offers.index');
Route::post('/offers/{offer}/accept', [CrewPortal\OfferController::class, 'accept'])
->name('offers.accept');
Route::post('/offers/{offer}/decline', [CrewPortal\OfferController::class, 'decline'])
->name('offers.decline');
Route::get('/assignments', [CrewPortal\AssignmentController::class, 'index'])
->name('assignments.index');
Route::get('/profile', [CrewPortal\ProfileController::class, 'edit'])
->name('profile.edit');
Route::put('/profile', [CrewPortal\ProfileController::class, 'update'])
->name('profile.update');
});
Environment Configuration
# .env
APP_URL=https://pbs.com
APP_DOMAIN=pbs.com
BACKEND_URL=https://backend.pbs.com
CREW_URL=https://crew.pbs.com
8. Reusable Component Patterns
Composable for Projects (like React Hooks)
// resources/js/composables/useProjects.js
import { ref, computed } from 'vue';
import { router } from '@inertiajs/vue3';
export function useProjects(initialProjects = []) {
const projects = ref(initialProjects);
const loading = ref(false);
const searchQuery = ref('');
const filteredProjects = computed(() => {
if (!searchQuery.value) return projects.value;
return projects.value.filter(project =>
project.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
project.location.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const activeProjects = computed(() =>
projects.value.filter(p => p.status === 'active')
);
const deleteProject = async (projectId) => {
if (!confirm('Delete this project?')) return;
loading.value = true;
router.delete(`/projects/${projectId}`, {
onFinish: () => {
loading.value = false;
}
});
};
return {
projects,
loading,
searchQuery,
filteredProjects,
activeProjects,
deleteProject
};
}
Usage in Component
<script setup>
import { useProjects } from '@/composables/useProjects';
const props = defineProps({
initialProjects: Array
});
const {
projects,
searchQuery,
filteredProjects,
activeProjects,
deleteProject
} = useProjects(props.initialProjects);
</script>
<template>
<div>
<input v-model="searchQuery" placeholder="Search..." />
<div v-for="project in filteredProjects" :key="project.id">
{{ project.name }}
<button @click="deleteProject(project.id)">Delete</button>
</div>
</div>
</template>
Summary
These examples show:
- ✅ Simple Setup - Laravel Breeze makes it easy
- ✅ No API Needed - Inertia handles data passing
- ✅ Clean Code - Vue templates are HTML-like
- ✅ Forms Are Easy -
v-modelsimplifies two-way binding - ✅ Multi-tenancy Works - Subdomain routing is clean
- ✅ Mobile-First - Tailwind makes responsive design easy
- ✅ Reusable Logic - Composables work like React hooks
Next Steps:
- Copy these patterns into your PBS Planning project
- Adapt to your specific schema.dbml structure
- Build out remaining views following these examples
Questions? Refer to: