feat: Add Blade templates for ops QR interface (Phase 2)
Create state-driven UI templates: Main Template (ops/order-detail.blade.php): - Display order summary with status, type, customer, date - Render QR code SVG for shop-floor access - Conditionally include state-specific action forms - Show order items, packing details, shipment info Action Templates: - ops/actions/packing.blade.php: Form to input weight/dimensions - ops/actions/inspection.blade.php: Pass/Fail inspection with optional notes - ops/actions/read-only.blade.php: Status display for read-only states Error Pages: - ops/order-not-found.blade.php: Invalid/expired QR token - ops/unauthorized.blade.php: Insufficient permissions All forms use AJAX submission with confirmation dialogs
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
<!-- Inspection Actions -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">🔍 Inspection</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Inspection Passed -->
|
||||||
|
<div class="border-2 border-green-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold text-green-700 mb-3">✓ Passed Inspection</h3>
|
||||||
|
<p class="text-gray-600 mb-4">Quality check completed - proceed to packing</p>
|
||||||
|
|
||||||
|
<form action="{{ route('ops.order.inspection-passed', $order) }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded transition duration-200"
|
||||||
|
>
|
||||||
|
Mark as Passed
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inspection Failed -->
|
||||||
|
<div class="border-2 border-red-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold text-red-700 mb-3">✗ Flag Issue</h3>
|
||||||
|
<p class="text-gray-600 mb-4">Quality issue detected - needs review</p>
|
||||||
|
|
||||||
|
<form id="inspectionFailedForm" action="{{ route('ops.order.inspection-failed', $order) }}" method="POST" class="space-y-3">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
name="issue_description"
|
||||||
|
required
|
||||||
|
maxlength="500"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Describe the quality issue..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded transition duration-200"
|
||||||
|
>
|
||||||
|
Flag & Move to Review
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('inspectionFailedForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!confirm('Flag this order for review? It will be moved to review status.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('✓ Issue flagged - order moved to review');
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.message || data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error submitting form: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<!-- Packing Action Form -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">📦 Confirm Packing</h2>
|
||||||
|
|
||||||
|
<form id="packingForm" action="{{ route('ops.order.pack', $order) }}" method="POST" class="space-y-4">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<!-- Width -->
|
||||||
|
<div>
|
||||||
|
<label for="width" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Width (cm)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="width"
|
||||||
|
name="width"
|
||||||
|
step="0.1"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="e.g., 30"
|
||||||
|
>
|
||||||
|
@error('width')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Length -->
|
||||||
|
<div>
|
||||||
|
<label for="length" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Length (cm)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="length"
|
||||||
|
name="length"
|
||||||
|
step="0.1"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="e.g., 40"
|
||||||
|
>
|
||||||
|
@error('length')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Height -->
|
||||||
|
<div>
|
||||||
|
<label for="height" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Height (cm)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="height"
|
||||||
|
name="height"
|
||||||
|
step="0.1"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="e.g., 10"
|
||||||
|
>
|
||||||
|
@error('height')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weight -->
|
||||||
|
<div>
|
||||||
|
<label for="weight" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Weight (kg)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="weight"
|
||||||
|
name="weight"
|
||||||
|
step="0.1"
|
||||||
|
min="0.1"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="e.g., 2.5"
|
||||||
|
>
|
||||||
|
@error('weight')
|
||||||
|
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-6 rounded-lg transition duration-200"
|
||||||
|
>
|
||||||
|
✓ Confirm Packed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="reset"
|
||||||
|
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-6 rounded-lg transition duration-200"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('packingForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('✓ Order packed successfully!');
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.message || data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error submitting form: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!-- Read-Only State -->
|
||||||
|
<div class="bg-blue-50 border-2 border-blue-200 rounded-lg p-6 mb-8">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="text-2xl font-bold text-blue-900 mb-2">📋 Order Status: Read-Only</h2>
|
||||||
|
<p class="text-blue-800 mb-4">
|
||||||
|
This order is in the <strong>{{ str_replace('_', ' ', ucfirst($order->status)) }}</strong> phase.
|
||||||
|
No actions are available at this stage.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if($order->status === 'ready_to_ship')
|
||||||
|
<div class="bg-white border-l-4 border-blue-500 p-4 rounded">
|
||||||
|
<p class="text-gray-700">
|
||||||
|
<strong>Next Step:</strong> Move Trello card to "Ready to Ship" to trigger automatic shipment creation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@elseif($order->status === 'awaiting_collection')
|
||||||
|
<div class="bg-white border-l-4 border-blue-500 p-4 rounded">
|
||||||
|
<p class="text-gray-700">
|
||||||
|
<strong>Status:</strong> Parcel is awaiting collection from courier.
|
||||||
|
</p>
|
||||||
|
@if($order->courier_waybill_id)
|
||||||
|
<p class="text-gray-700 mt-2">
|
||||||
|
<strong>Waybill:</strong> <code class="bg-gray-100 px-2 py-1 rounded">{{ $order->courier_waybill_id }}</code>
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@elseif($order->status === 'in_transit')
|
||||||
|
<div class="bg-white border-l-4 border-green-500 p-4 rounded">
|
||||||
|
<p class="text-gray-700">
|
||||||
|
<strong>Status:</strong> Parcel is in transit to the customer.
|
||||||
|
</p>
|
||||||
|
@if($order->courier_tracking_number)
|
||||||
|
<p class="text-gray-700 mt-2">
|
||||||
|
<strong>Tracking:</strong> <code class="bg-gray-100 px-2 py-1 rounded">{{ $order->courier_tracking_number }}</code>
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="bg-white border-l-4 border-gray-500 p-4 rounded">
|
||||||
|
<p class="text-gray-700">
|
||||||
|
The order is progressing through the fulfillment pipeline.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold mb-2">Order {{ $order->order_number }}</h1>
|
||||||
|
<p class="text-gray-600">{{ $order->uuid }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Summary Card -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Status</p>
|
||||||
|
<p class="text-lg font-semibold capitalize">{{ str_replace('_', ' ', $order->status) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Order Type</p>
|
||||||
|
<p class="text-lg font-semibold">{{ $order->is_custom_order ? 'Custom' : 'Standard' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Customer</p>
|
||||||
|
<p class="text-lg font-semibold">{{ $order->user->name ?? 'N/A' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Created</p>
|
||||||
|
<p class="text-lg font-semibold">{{ $order->created_at->format('M d, Y') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code -->
|
||||||
|
<div class="border-t pt-6">
|
||||||
|
<p class="text-sm text-gray-600 mb-3">Scan to access this order:</p>
|
||||||
|
<div class="inline-block bg-gray-50 p-4 rounded border">
|
||||||
|
{!! file_get_contents(storage_path("app/public/qr-codes/{$order->uuid}.svg")) !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- State-Specific Actions -->
|
||||||
|
@if($order->status === 'inspection')
|
||||||
|
@include('ops.actions.inspection')
|
||||||
|
@elseif($order->status === 'packing' || $order->status === 'printing')
|
||||||
|
@include('ops.actions.packing')
|
||||||
|
@elseif(in_array($order->status, ['ready_to_ship', 'awaiting_collection', 'in_transit']))
|
||||||
|
@include('ops.actions.read-only')
|
||||||
|
@else
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<p class="text-yellow-800">No actions available for this order status.</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Order Items -->
|
||||||
|
<div class="mt-8 bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Order Items</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b">
|
||||||
|
<th class="text-left py-3 px-4">Product</th>
|
||||||
|
<th class="text-left py-3 px-4">Type</th>
|
||||||
|
<th class="text-left py-3 px-4">Quantity</th>
|
||||||
|
<th class="text-left py-3 px-4">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($order->items as $item)
|
||||||
|
<tr class="border-b hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4">{{ $item->product->name ?? 'N/A' }}</td>
|
||||||
|
<td class="py-3 px-4">{{ $item->product_type ?? 'N/A' }}</td>
|
||||||
|
<td class="py-3 px-4">{{ $item->quantity }}</td>
|
||||||
|
<td class="py-3 px-4">R{{ number_format($item->unit_price * $item->quantity, 2) }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="py-3 px-4 text-center text-gray-500">No items</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Packing Details (if available) -->
|
||||||
|
@if($order->packing_completed_at)
|
||||||
|
<div class="mt-8 bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Packing Details</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Width</p>
|
||||||
|
<p class="text-lg font-semibold">{{ $order->packing_width }} cm</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Length</p>
|
||||||
|
<p class="text-lg font-semibold">{{ $order->packing_length }} cm</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Weight</p>
|
||||||
|
<p class="text-lg font-semibold">{{ $order->packing_weight }} kg</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Packed At</p>
|
||||||
|
<p class="text-lg font-semibold">{{ $order->packing_completed_at->format('M d, H:i') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Shipment Details (if available) -->
|
||||||
|
@if($order->courier_waybill_id)
|
||||||
|
<div class="mt-8 bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Shipment Details</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Waybill ID</p>
|
||||||
|
<p class="text-lg font-semibold font-mono">{{ $order->courier_waybill_id }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Tracking Number</p>
|
||||||
|
<p class="text-lg font-semibold font-mono">{{ $order->courier_tracking_number ?? 'N/A' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600">Status</p>
|
||||||
|
<p class="text-lg font-semibold capitalize">{{ str_replace('_', ' ', $order->courier_status ?? 'pending') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
||||||
|
<h1 class="text-4xl font-bold text-red-900 mb-3">❌ Order Not Found</h1>
|
||||||
|
<p class="text-red-700 mb-6 text-lg">
|
||||||
|
The QR code token is invalid or the order does not exist.
|
||||||
|
</p>
|
||||||
|
<a href="{{ route('home') }}" class="inline-block bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-6 rounded">
|
||||||
|
Return Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
|
||||||
|
<h1 class="text-4xl font-bold text-yellow-900 mb-3">🔒 Access Denied</h1>
|
||||||
|
<p class="text-yellow-700 mb-6 text-lg">
|
||||||
|
You do not have permission to access this page. Only operations staff can view QR-based order interfaces.
|
||||||
|
</p>
|
||||||
|
@if(auth()->check())
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
Logged in as: <strong>{{ auth()->user()->name }}</strong>
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
<a href="{{ route('home') }}" class="inline-block bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-6 rounded">
|
||||||
|
Return Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
Reference in New Issue
Block a user