Skip to main content

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

  1. Project Setup
  2. Authentication
  3. Project List Page
  4. Create Project Form
  5. Multi-Tenancy
  6. Crew Portal Example
  7. Subdomain Routing
  8. 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:

  1. Simple Setup - Laravel Breeze makes it easy
  2. No API Needed - Inertia handles data passing
  3. Clean Code - Vue templates are HTML-like
  4. Forms Are Easy - v-model simplifies two-way binding
  5. Multi-tenancy Works - Subdomain routing is clean
  6. Mobile-First - Tailwind makes responsive design easy
  7. 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: