It is time to implement the last Order Management feature for the staff
role.
The page will contain two lists of orders.
pending
.preparing
or rejected with the status cancelled
.preparing
and can be marked as ready
for delivery or cancelled
.Lists completed orders with the status ready
or rejected with the status cancelled
. In other words, these are fulfilled orders, and no further action can be taken.
Let's update the createStaffRole()
method in RoleSeeder
and add order.update
permission to the staff
role.
database/seeders/RoleSeeder.php
public function createStaffRole(){ $permissions = Permission::whereIn('name', [ 'order.update', ])->get(); $this->createRole(RoleName::STAFF, $permissions);}
To make things easier to test, let's add the staff()
method to UserFactory
so we can easily assign that role through Factory.
database/factories/UserFactory.php
public function staff(){ return $this->afterCreating(function (User $user) { $user->roles()->sync(Role::where('name', RoleName::STAFF->value)->first()); });}
Then update the seedDemoRestaurants()
method in DatabaseSeeder
. This will create staff users for every restaurant seeded.
database/seeders/DatabaseSeeder.php
public function seedDemoRestaurants(){ $products = Product::factory(7); $categories = Category::factory(5)->has($products); $staffMember = User::factory()->staff(); $restaurant = Restaurant::factory()->has($categories)->has($staffMember, 'staff'); User::factory(50)->vendor()->has($restaurant)->create();}
Because the relationship and Factory Model are different and can't be matched automatically, we specify the relationship name as a second argument in the ->has(..., 'staff')
method.
Add static methods toArray()
and values()
to the OrderStatus
enum.
app/Enums/OrderStatus.php
public static function toArray(): array{ $cases = []; foreach (self::cases() as $case) { $cases[$case->name] = $case->value; } return $cases;} public static function values(): array{ return array_column(self::cases(), 'value');}
The toArray()
method will help transform the enum into an array. We will pass it to the view to let the front end know our possible statuses. It will return the following array.
> OrderStatus::toArray()= [ "PENDING" => "pending", "PREPARING" => "preparing", "READY" => "ready", "CANCELLED" => "cancelled", ]
The values()
method will return only values of the OrderStatus
enum. It will become handy when we validate the order status in the update request.
> OrderStatus::values()= [ "pending", "preparing", "ready", "cancelled", ]
Now, add two Eloquent Local Scopes to the Order
Model. Scopes help us filter orders by their status by calling a single current()
or past()
method.
app/Models/Order.php
public function scopeCurrent($query): void{ $query->whereIn('status', [ OrderStatus::PENDING, OrderStatus::PREPARING, ]);} public function scopePast($query): void{ $query->whereIn('status', [ OrderStatus::READY, OrderStatus::CANCELLED, ]);}
Create an OrderController
for staff members.
php artisan make:controller Staff/OrderController
app/Http/Controllers/Staff/OrderController.php
namespace App\Http\Controllers\Staff; use App\Enums\OrderStatus;use App\Http\Controllers\Controller;use App\Http\Requests\Staff\UpdateOrderRequest;use App\Models\Order;use Inertia\Inertia;use Inertia\Response; class OrderController extends Controller{ public function index(): Response { $currentOrders = Order::current() ->with(['customer', 'products']) ->where('restaurant_id', auth()->user()->restaurant_id) ->latest() ->get(); $pastOrders = Order::past() ->with(['customer', 'products']) ->where('restaurant_id', auth()->user()->restaurant_id) ->latest() ->get(); return Inertia::render('Staff/Orders', [ 'current_orders' => $currentOrders, 'past_orders' => $pastOrders, 'order_status' => OrderStatus::toArray(), ]); } public function update(UpdateOrderRequest $request, $orderId) { $order = Order::where('restaurant_id', $request->user()->restaurant_id) ->findOrFail($orderId); $order->update($request->validated()); return back(); }}
In the index()
method, we can use current()
and past()
methods on the Order
Model to query orders that fall into constraints we defined in the scope.
The OrderStatus
is transformed to an array using the toArray()
method we just added to that enum class.
'order_status' => OrderStatus::toArray(),
We can re-use that enum like this order_status.PREPARING
in Vue files.
Now let's talk about the update()
method. Why didn't we resolve the Order
Model like usual?
public function update(UpdateOrderRequest $request, Order $order) { $order->update($request->validated()); //...}
It would help if you carefully used Route Model Binding with tenancy features. Tenancy refers to architecture when a single instance serves multiple independent clients or tenants. In our case, the order can belong to a different restaurant.
The problem is that using the update(..., Order $order)
method signature, ANY existing order in the database can be updated. We want everyone to refrain from messing with other restaurants' orders.
To fix this problem, we resolve the order we want to update by the id and the restaurant of the currently logged-in staff member.
public function update(UpdateOrderRequest $request, $orderId){ $order = Order::where('restaurant_id', $request->user()->restaurant_id) ->findOrFail($orderId); // ...}
Create a new Request class.
app/Http/Requests/Staff/UpdateOrderRequest.php
namespace App\Http\Requests\Staff; use App\Enums\OrderStatus;use Illuminate\Foundation\Http\FormRequest;use Illuminate\Support\Facades\Gate;use Illuminate\Validation\Rule; class UpdateOrderRequest extends FormRequest{ public function authorize(): bool { return Gate::allows('order.update'); } public function rules(): array { return [ 'status' => ['required', Rule::in(OrderStatus::values())], ]; }}
Create new routes file for staff.
routes/staff.php
use App\Http\Controllers\Staff\OrderController;use Illuminate\Support\Facades\Route; Route::group([ 'prefix' => 'staff', 'as' => 'staff.', 'middleware' => ['auth'],], function () { Route::resource('orders', OrderController::class);});
And include it in the web.php
file.
routes/web.php
require __DIR__ . '/admin.php';require __DIR__ . '/vendor.php';require __DIR__ . '/customer.php';require __DIR__ . '/staff.php';
It is time to create the Orders
page.
resources/js/Pages/Staff/Orders.vue
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'import { Head, useForm } from '@inertiajs/vue3'import PrimaryButton from '@/Components/PrimaryButton.vue'import SecondaryButton from '@/Components/SecondaryButton.vue'import DangerButton from '@/Components/DangerButton.vue' defineProps({ current_orders: { type: Array }, past_orders: { type: Array }, order_status: { type: Object }}) const form = useForm({ status: null}) const updateStatus = (order, status) => { form.status = status form.put(route('staff.orders.update', order), { preserveScroll: true })}</script> <template> <Head title="Restaurant Orders" /> <AuthenticatedLayout> <template #header> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Restaurant Orders</h2> </template> <div class="py-12"> <!-- Current Orders --> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mb-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900 overflow-x-scroll"> <header> <h2 class="text-lg font-medium text-gray-900">Current Orders</h2> </header> <table class="table mt-6"> <thead> <tr> <th>Order ID</th> <th>Items</th> <th>Customer</th> <th>Total</th> <th>Status</th> <th></th> </tr> </thead> <tbody class="align-top"> <tr v-for="order in current_orders" :key="order.id"> <td>{{ order.id }}</td> <td> <div v-for="product in order.products" :key="product.id" class="border-b"> {{ product.name }} </div> </td> <td>{{ order.customer.name }}</td> <td> <div class="whitespace-nowrap">{{ (order.total / 100).toFixed(2) }} €</div> </td> <td> <div class="badge" :class="{ 'badge-yellow': order.status === order_status.PENDING, 'badge-secondary': order.status === order_status.PREPARING }" > {{ order.status }} </div> </td> <td> <div class="flex flex-row gap-2 justify-end"> <SecondaryButton v-if="order.status === order_status.PENDING" @click="updateStatus(order, order_status.PREPARING)" class="btn-sm" type="button" > Prepare </SecondaryButton> <PrimaryButton v-if="order.status === order_status.PREPARING" @click="updateStatus(order, order_status.READY)" class="btn-sm" type="button" > Ready </PrimaryButton> <DangerButton @click="updateStatus(order, order_status.CANCELLED)" class="btn-sm" type="button" > Cancel </DangerButton> </div> </td> </tr> </tbody> </table> </div> </div> </div> <!-- Past Orders --> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mb-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900 overflow-x-scroll"> <header> <h2 class="text-lg font-medium text-gray-900">Past Orders</h2> </header> <table class="table mt-6"> <thead> <tr> <th>Order ID</th> <th>Items</th> <th>Customer</th> <th>Total</th> <th>Status</th> </tr> </thead> <tbody class="align-top"> <tr v-for="order in past_orders" :key="order.id"> <td>{{ order.id }}</td> <td> <div v-for="product in order.products" :key="product.id" class="border-b"> {{ product.name }} </div> </td> <td>{{ order.customer.name }}</td> <td> <div class="whitespace-nowrap">{{ (order.total / 100).toFixed(2) }} €</div> </td> <td> <div class="badge" :class="{ 'badge-primary': order.status === order_status.READY, 'badge-danger': order.status === order_status.CANCELLED }" > {{ order.status }} </div> </td> </tr> </tbody> </table> </div> </div> </div> </div> </AuthenticatedLayout></template>
Then insert the Restaurant Orders navigation link to the top bar.
resources/js/Layouts/AuthenticatedLayout.vue
<template> <!-- ... --> Staff Management </NavLink> <NavLink v-if="can('order.update')" :href="route('staff.orders.index')" :active="route().current('staff.orders.index')" > Restaurant Orders </NavLink> </div> </div> <!-- ... --></template>
If you want your buttons to be consistent with the primary button and add more badge styles, feel free to apply these changes.
resources/css/app.css
.badge-danger { @apply bg-danger-50 text-danger-700 border-danger-300;} .badge-secondary { @apply bg-gray-50 text-gray-700 border-gray-300;} .badge-yellow { @apply bg-yellow-50 text-yellow-700 border-yellow-300;}
resources/js/Components/DangerButton.vue
<template> <button class="btn btn-danger"> <slot /> </button></template>
resources/js/Components/SecondaryButton.vue
<template> <button :type="type" class="btn btn-secondary"> <slot /> </button></template>
Congratulations, you've completed the course!
The code is in the repository on GitHub.