Migrate HickLab3D dashboard and add production auto deployment
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 19s
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 19s
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--color-brand: #e3f504;
|
||||
--color-slate-950: #0b0c0d;
|
||||
--color-slate-900: #17191b;
|
||||
--color-slate-800: #292c30;
|
||||
--color-slate-700: #3c4146;
|
||||
--color-slate-500: #9a9fa3;
|
||||
--color-slate-400: #b8bec4;
|
||||
--color-slate-300: #d6dade;
|
||||
--color-slate-100: #f4f5f5;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
//
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#0b0c0d"><link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}"><title>Sign in · HickLab3D</title>@vite(['resources/css/app.css', 'resources/js/app.js'])</head>
|
||||
<body class="grid min-h-screen place-items-center bg-slate-950 px-5 font-sans text-slate-100">
|
||||
<main class="w-full max-w-lg rounded-3xl border border-slate-800 bg-slate-900 p-6 shadow-2xl shadow-black/30 sm:p-8">
|
||||
<div class="mb-8 rounded-2xl bg-[#d8dad8] p-4 ring-1 ring-white/15 sm:p-6"><img src="{{ asset('hicklab3d-full.png') }}" alt="HickLab3D" class="mx-auto h-auto w-full max-w-[390px]"></div>
|
||||
<h2 class="text-xl font-semibold">Welcome back</h2><p class="mt-1 text-sm text-slate-400">Sign in to your dashboard.</p>
|
||||
<form method="post" action="{{ route('login.store') }}" class="mt-7 flex flex-col gap-5">@csrf
|
||||
<label class="flex flex-col gap-2 text-sm font-medium">Username<input name="username" value="{{ old('username') }}" required autocomplete="username" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 outline-none focus:border-brand"></label>
|
||||
<label class="flex flex-col gap-2 text-sm font-medium">Password<input type="password" name="password" required autocomplete="current-password" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 outline-none focus:border-brand"></label>
|
||||
@error('username')<p role="alert" class="text-sm text-rose-400">{{ $message }}</p>@enderror
|
||||
<button class="rounded-xl bg-brand px-4 py-3 font-semibold text-slate-950 transition-colors hover:bg-brand/80">Sign in</button>
|
||||
</form>
|
||||
</main></body></html>
|
||||
@@ -0,0 +1,19 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Overview')
|
||||
@section('content')
|
||||
<section class="flex flex-wrap items-center justify-between gap-6 overflow-hidden rounded-2xl border border-brand/20 bg-gradient-to-r from-brand/10 via-slate-900 to-slate-900 p-6 sm:p-8">
|
||||
<div class="max-w-2xl"><p class="mb-3 text-xs font-bold uppercase tracking-[.22em] text-brand">HickLab3D workshop</p><h2 class="text-2xl font-bold sm:text-3xl">Good to see you, {{ auth()->user()->username }}.</h2><p class="mt-2 text-slate-400">Sales, production, materials and dispatch in one place.</p></div>
|
||||
<div class="grid size-32 shrink-0 place-items-center rounded-2xl bg-[#d8dad8] p-2 ring-1 ring-white/15"><img src="{{ asset('hicklab3d-monogram.png') }}" alt="HickLab3D monogram" class="max-h-full max-w-full object-contain"></div>
|
||||
</section>
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@if(auth()->user()->role === 'admin')<a href="{{ route('etsy.index') }}" class="rounded-2xl border border-slate-800 bg-slate-900 p-6 transition hover:border-brand/50"><p class="text-sm text-slate-400">Sales channel</p><h3 class="mt-5 text-2xl font-bold">Etsy shop</h3><p class="mt-4 text-sm text-brand">Orders and listings →</p></a>@endif
|
||||
<a href="{{ route('printers.index') }}" class="rounded-2xl border border-slate-800 bg-slate-900 p-6 transition hover:border-brand/50"><p class="text-sm text-slate-400">Production</p><h3 class="mt-5 text-2xl font-bold">BamBuddy</h3><p class="mt-4 text-sm text-brand">Live print farm →</p></a>
|
||||
<a href="{{ route('filaments.index') }}" class="rounded-2xl border border-slate-800 bg-slate-900 p-6 transition hover:border-brand/50"><p class="text-sm text-slate-400">Materials</p><h3 class="mt-5 text-2xl font-bold">{{ $filamentCount }} tracked</h3><p class="mt-4 text-sm text-brand">Filament prices →</p></a>
|
||||
@if(auth()->user()->role === 'admin')<a href="{{ route('shipping') }}" class="rounded-2xl border border-slate-800 bg-slate-900 p-6 transition hover:border-brand/50"><p class="text-sm text-slate-400">Dispatch</p><h3 class="mt-5 text-2xl font-bold">Parcel2Go</h3><p class="mt-4 text-sm text-brand">Quotes and shipments →</p></a>@endif
|
||||
<a href="{{ route('dryer') }}" class="rounded-2xl border border-slate-800 bg-slate-900 p-6 transition hover:border-brand/50"><p class="text-sm text-slate-400">Workshop tools</p><h3 class="mt-5 text-2xl font-bold">Filament dryer</h3><p class="mt-4 text-sm text-brand">Status and controls →</p></a>
|
||||
@if($userCount !== null)<a href="{{ route('users.index') }}" class="rounded-2xl border border-slate-800 bg-slate-900 p-6 transition hover:border-brand/50"><p class="text-sm text-slate-400">Team</p><h3 class="mt-5 text-2xl font-bold">{{ $userCount }} accounts</h3><p class="mt-4 text-sm text-brand">Manage access →</p></a>@endif
|
||||
</div>
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><div class="mb-5 flex items-center justify-between"><h2 class="text-lg font-semibold">Recently tracked filament</h2><a class="text-sm text-brand" href="{{ route('filaments.index') }}">View all →</a></div>
|
||||
<div class="divide-y divide-slate-800">@forelse($recentFilaments as $filament)<div class="flex items-center justify-between gap-4 py-4"><div><p class="font-medium">{{ $filament->filamentName }}</p><p class="text-sm text-slate-400">{{ $filament->brand ?: 'Unknown brand' }} · {{ $filament->material ?: 'Material pending' }}</p></div><span class="font-semibold">{{ $filament->prices->first() ? '£'.number_format($filament->prices->first()->price, 2) : 'Pending' }}</span></div>@empty<p class="py-4 text-sm text-slate-400">No filament is being tracked yet.</p>@endforelse</div>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,63 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Filament dryer')
|
||||
@section('content')
|
||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Workshop tools</p><h2 class="mt-2 text-2xl font-bold">Filament dryer</h2><p class="mt-1 text-slate-400">Live programme, sensor readings and relay state.</p></div><a href="https://filamentdryer.hickmeister.uk" target="_blank" rel="noopener noreferrer" class="rounded-xl border border-brand/40 px-4 py-3 text-sm font-semibold text-brand hover:bg-brand/10">Open dryer ↗</a></div>
|
||||
<p id="dryer-message" role="status" class="rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-400">Connecting to dryer…</p>
|
||||
<div id="dryer-fault" role="alert" hidden class="rounded-xl border border-rose-500/40 bg-rose-500/10 p-4 text-sm text-rose-200"><strong>Dryer fault</strong><p id="fault-detail" class="mt-1"></p></div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">@foreach([['dryer-state','State'],['temperature','Temperature'],['humidity','Humidity'],['remaining','Remaining time']] as [$id,$label])<div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">{{ $label }}</p><p id="{{ $id }}" class="mt-5 text-3xl font-bold">—</p></div>@endforeach</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2"><section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">Current programme</h3><dl class="mt-5 grid grid-cols-2 gap-4 text-sm"><div><dt class="text-slate-400">Profile</dt><dd id="profile" class="mt-1 font-semibold">—</dd></div><div><dt class="text-slate-400">Target</dt><dd id="target" class="mt-1 font-semibold">—</dd></div><div><dt class="text-slate-400">Heater</dt><dd id="heater" class="mt-1 font-semibold">—</dd></div><div><dt class="text-slate-400">Fan</dt><dd id="fan" class="mt-1 font-semibold">—</dd></div></dl></section>
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">Recent samples</h3><p class="mt-1 text-sm text-slate-400">Latest readings from the controller.</p><div id="dryer-history" class="mt-4 flex flex-wrap gap-2 text-xs text-slate-400">Waiting for history…</div></section></div>
|
||||
@if(auth()->user()->role === 'admin')<form id="dryer-form" class="grid gap-5 rounded-2xl border border-slate-800 bg-slate-900 p-6"><div><h3 class="text-lg font-semibold">Start a drying cycle</h3><p class="mt-1 text-sm text-slate-400">The controller checks temperature, timing and fault limits before heating.</p></div><div class="grid gap-4 sm:grid-cols-3"><label class="grid gap-2 text-sm">Material<select id="preset" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"><option value="PLA" data-temp="50">PLA · 50°C</option><option value="PETG" data-temp="60">PETG · 60°C</option><option value="ABS" data-temp="65">ABS · 65°C</option><option value="Nylon" data-temp="65">Nylon · 65°C</option><option value="Custom" data-temp="50">Custom</option></select></label><label class="grid gap-2 text-sm">Target °C<input id="target-temperature" type="number" min="45" max="70" value="50" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label><label class="grid gap-2 text-sm">Duration, minutes<input id="duration" type="number" min="5" max="2880" value="240" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label></div><div class="flex flex-wrap gap-3"><button id="start-dryer" class="rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950 hover:bg-brand/90">Start cycle</button><button type="button" id="stop-dryer" class="rounded-xl border border-rose-500/40 px-5 py-3 text-rose-300 hover:bg-rose-500/10">Stop cycle</button><button type="button" id="acknowledge-fault" hidden class="rounded-xl border border-amber-500/40 px-5 py-3 text-amber-200 hover:bg-amber-500/10">Acknowledge fault</button></div></form>@endif
|
||||
@push('scripts')<script>
|
||||
const statusUrl = @json(route('dryer.status'));
|
||||
const controlUrl = @json(route('dryer.control'));
|
||||
const acknowledgeUrl = @json(route('dryer.acknowledge'));
|
||||
const message = document.getElementById('dryer-message');
|
||||
const display = (id, value) => { document.getElementById(id).textContent = value; };
|
||||
async function refreshDryer() {
|
||||
try {
|
||||
const response = await fetch(statusUrl, {credentials: 'same-origin', cache: 'no-store'});
|
||||
if (!response.ok) throw new Error('Dryer is unavailable. Check its connection and API key.');
|
||||
const data = await response.json();
|
||||
const status = data.status || {};
|
||||
const sample = status.sample || {};
|
||||
const programme = status.programme || {};
|
||||
const outputs = status.outputs || {};
|
||||
const fault = status.fault || {};
|
||||
display('dryer-state', status.state || 'Unknown');
|
||||
display('temperature', sample.valid && sample.temperatureC != null ? Number(sample.temperatureC).toFixed(1) + '°C' : 'Sensor unavailable');
|
||||
display('humidity', sample.valid && sample.humidityPct != null ? Number(sample.humidityPct).toFixed(1) + '%' : '—');
|
||||
display('remaining', programme.remainingSeconds != null ? Math.ceil(programme.remainingSeconds / 60) + ' min' : '—');
|
||||
display('profile', status.profile || '—');
|
||||
display('target', programme.targetC ? Number(programme.targetC).toFixed(0) + '°C' : '—');
|
||||
display('heater', outputs.heater ? 'On' : 'Off');
|
||||
display('fan', outputs.fan ? 'On' : 'Off');
|
||||
document.getElementById('dryer-fault').hidden = !fault.latched;
|
||||
document.getElementById('fault-detail').textContent = fault.message || fault.code || 'Controller reported a fault.';
|
||||
const acknowledge = document.getElementById('acknowledge-fault');
|
||||
if (acknowledge) acknowledge.hidden = !fault.latched;
|
||||
const samples = Array.isArray(data.history?.samples) ? data.history.samples.slice(-8) : [];
|
||||
const history = document.getElementById('dryer-history');
|
||||
history.replaceChildren();
|
||||
if (!samples.length) history.textContent = 'No samples yet.';
|
||||
for (const sample of samples) {
|
||||
const item = document.createElement('span');
|
||||
item.className = 'rounded-lg bg-slate-950 px-3 py-2';
|
||||
item.textContent = Number(sample.temperatureC).toFixed(1) + '°C · ' + Number(sample.humidityPct).toFixed(0) + '%';
|
||||
history.append(item);
|
||||
}
|
||||
message.textContent = 'Controller online · updated ' + new Date().toLocaleTimeString();
|
||||
} catch (error) { message.textContent = error.message; }
|
||||
}
|
||||
async function sendDryerCommand(url, body) {
|
||||
const response = await fetch(url, {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content}, body: JSON.stringify(body)});
|
||||
if (!response.ok) throw new Error('The controller did not accept that command.');
|
||||
await refreshDryer();
|
||||
}
|
||||
document.getElementById('preset')?.addEventListener('change', event => {document.getElementById('target-temperature').value = event.target.selectedOptions[0].dataset.temp;});
|
||||
document.getElementById('dryer-form')?.addEventListener('submit', async event => {event.preventDefault(); const button = document.getElementById('start-dryer'); button.disabled = true; try {await sendDryerCommand(controlUrl, {dryerOn: true, profile: document.getElementById('preset').value, targetTemperature: Number(document.getElementById('target-temperature').value), dryingTime: Number(document.getElementById('duration').value)})} catch (error) {message.textContent = error.message} finally {button.disabled = false}});
|
||||
document.getElementById('stop-dryer')?.addEventListener('click', async () => {if (confirm('Stop the current drying cycle?')) {try {await sendDryerCommand(controlUrl, {dryerOn: false})} catch (error) {message.textContent = error.message}}});
|
||||
document.getElementById('acknowledge-fault')?.addEventListener('click', async () => {if (confirm('Acknowledge the dryer fault? Only do this after checking the appliance.')) {try {await sendDryerCommand(acknowledgeUrl, {})} catch (error) {message.textContent = error.message}}});
|
||||
refreshDryer(); setInterval(refreshDryer, 10000);
|
||||
</script>@endpush
|
||||
@endsection
|
||||
@@ -0,0 +1,12 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Etsy shop')
|
||||
@section('content')
|
||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Sales</p><h2 class="mt-2 text-2xl font-bold">{{ $connection?->shop_name ?? 'Connect your Etsy shop' }}</h2><p class="mt-1 text-slate-400">Paid orders awaiting dispatch and active listings.</p></div><form method="post" action="{{ route('etsy.connect') }}">@csrf<button class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">{{ $connection ? 'Reconnect Etsy' : 'Connect Etsy' }}</button></form></div>
|
||||
@if(! $configured)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">Add ETSY_KEYSTRING and ETSY_SHARED_SECRET to the application environment, then register {{ route('etsy.callback') }} as the Etsy redirect URI.</div>@endif
|
||||
@if($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endif
|
||||
@if($connection)
|
||||
<div class="grid gap-4 sm:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Awaiting dispatch</p><p class="mt-3 text-4xl font-bold">{{ count($receipts) }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Active listings shown</p><p class="mt-3 text-4xl font-bold">{{ count($listings) }}</p></div></div>
|
||||
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900"><h3 class="p-6 text-lg font-semibold">Orders to dispatch</h3><table class="w-full min-w-[650px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Receipt</th><th class="p-4">Buyer</th><th class="p-4">Placed</th><th class="p-4">Total</th></tr></thead><tbody class="divide-y divide-slate-800">@forelse($receipts as $receipt)<tr><td class="p-4 font-medium">#{{ $receipt['receipt_id'] ?? '—' }}</td><td class="p-4">{{ $receipt['name'] ?? $receipt['buyer_email'] ?? 'Buyer' }}</td><td class="p-4">{{ isset($receipt['create_timestamp']) ? \Illuminate\Support\Carbon::createFromTimestamp($receipt['create_timestamp'])->format('d M Y') : '—' }}</td><td class="p-4">{{ isset($receipt['grandtotal']['amount']) ? ($receipt['grandtotal']['currency_code'] ?? 'GBP').' '.number_format((float) $receipt['grandtotal']['amount'] / max(1, (int) ($receipt['grandtotal']['divisor'] ?? 100)), 2) : '—' }}</td></tr>@empty<tr><td colspan="4" class="p-6 text-slate-400">No paid orders awaiting dispatch.</td></tr>@endforelse</tbody></table></section>
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">Active listings</h3><div class="mt-4 divide-y divide-slate-800">@forelse($listings as $listing)<div class="flex items-center justify-between gap-4 py-3"><span>{{ $listing['title'] ?? 'Untitled listing' }}</span><span class="shrink-0 text-sm text-slate-400">{{ $listing['quantity'] ?? 0 }} available</span></div>@empty<p class="py-3 text-sm text-slate-400">No listings returned.</p>@endforelse</div></section>
|
||||
@endif
|
||||
@endsection
|
||||
@@ -0,0 +1,10 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Track filament')
|
||||
@section('content')
|
||||
<form method="post" action="{{ route('filaments.store') }}" class="grid max-w-2xl gap-5 rounded-2xl border border-slate-800 bg-slate-900 p-6">@csrf
|
||||
@foreach([['filamentName','Filament name','e.g. PLA Matte Black'],['amazonUrl','Product URL','https://www.amazon.co.uk/...'],['brand','Brand','Optional'],['material','Material','PLA, PETG, ABS…'],['color','Colour','Optional'],['filamentWeight','Weight (g)','1000'],['itemDiameter','Diameter (mm)','1.75']] as [$name,$label,$placeholder])
|
||||
<label class="grid gap-2 text-sm font-medium">{{ $label }}<input name="{{ $name }}" type="{{ $name === 'amazonUrl' ? 'url' : (in_array($name, ['filamentWeight','itemDiameter']) ? 'number' : 'text') }}" @if(in_array($name, ['filamentWeight','itemDiameter'])) step="0.01" @endif value="{{ old($name) }}" placeholder="{{ $placeholder }}" @if(in_array($name, ['filamentName','amazonUrl'])) required @endif class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 outline-none focus:border-brand"></label>
|
||||
@endforeach
|
||||
<div class="flex gap-3"><button class="rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Start tracking</button><a href="{{ route('filaments.index') }}" class="rounded-xl border border-slate-700 px-5 py-3">Cancel</a></div>
|
||||
</form>
|
||||
@endsection
|
||||
@@ -0,0 +1,10 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Filament tracker')
|
||||
@section('content')
|
||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><h2 class="text-2xl font-bold">Filament prices</h2><p class="mt-1 text-slate-400">Recent prices and offers from tracked listings.</p></div>@if(auth()->user()->role === 'admin')<a href="{{ route('filaments.create') }}" class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950">Track filament</a>@endif</div>
|
||||
<div class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900"><table class="w-full min-w-[700px] text-left text-sm"><thead class="border-b border-slate-800 text-xs uppercase tracking-widest text-slate-500"><tr><th class="p-5">Filament</th><th class="p-5">Material</th><th class="p-5">Latest price</th><th class="p-5">Discount</th><th class="p-5">Updated</th><th class="p-5"></th></tr></thead><tbody class="divide-y divide-slate-800">@forelse($filaments as $filament)
|
||||
@php $latest = $filament->prices->first(); $discount = $latest?->currentDiscount['discount']['value'] ?? 0; @endphp
|
||||
<tr><td class="p-5"><a href="{{ $filament->amazonUrl }}" target="_blank" rel="noopener noreferrer" class="font-semibold text-brand hover:underline">{{ $filament->filamentName }} ↗</a><p class="mt-1 text-slate-400">{{ $filament->brand ?: 'Unknown brand' }} · {{ $filament->color ?: 'Colour unknown' }}</p></td><td class="p-5">{{ $filament->material ?: '—' }}</td><td class="p-5 font-semibold">{{ $latest ? '£'.number_format($latest->price, 2) : 'Pending' }}</td><td class="p-5">{{ $discount ? $discount.'%' : '—' }}</td><td class="p-5 text-slate-400">{{ $latest?->recordedAt?->diffForHumans() ?? '—' }}</td><td class="p-5">@if(auth()->user()->role === 'admin')<form method="post" action="{{ route('filaments.destroy', $filament) }}" onsubmit="return confirm('Stop tracking this filament?')">@csrf @method('DELETE')<button class="text-rose-400">Remove</button></form>@endif</td></tr>
|
||||
@empty<tr><td colspan="6" class="p-8 text-center text-slate-400">No filament is being tracked yet.</td></tr>@endforelse</tbody></table></div>
|
||||
{{ $filaments->links() }}
|
||||
@endsection
|
||||
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0b0c0d">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}">
|
||||
<title>@yield('title', 'Dashboard') · HickLab3D</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 font-sans text-slate-100 antialiased">
|
||||
<div class="min-h-screen lg:flex">
|
||||
<aside class="border-b border-slate-800 bg-slate-900/80 lg:fixed lg:inset-y-0 lg:w-64 lg:border-r lg:border-b-0">
|
||||
<div class="flex h-20 items-center gap-3 px-6">
|
||||
<a href="{{ route('dashboard') }}" class="grid size-10 shrink-0 place-items-center rounded-xl bg-brand p-1.5 transition-transform hover:scale-105" aria-label="HickLab3D overview"><img src="{{ asset('hicklab3d-touchmark.svg') }}" alt="" class="size-full object-contain"></a>
|
||||
<div><strong class="block text-lg tracking-tight">HickLab3D</strong><span class="text-xs text-slate-400">Operations studio</span></div>
|
||||
</div>
|
||||
<nav class="flex gap-1 overflow-x-auto px-4 pb-4 lg:flex-col lg:overflow-visible" aria-label="Main navigation">
|
||||
@php $links = [['dashboard', 'Overview'], ['printers.index', 'Print farm'], ['etsy.index', 'Etsy'], ['filaments.index', 'Filament'], ['shipping', 'Shipping'], ['dryer', 'Dryer']]; @endphp
|
||||
@foreach($links as [$routeName, $label])
|
||||
@if(! in_array($routeName, ['shipping', 'etsy.index']) || auth()->user()->role === 'admin')
|
||||
<a href="{{ route($routeName) }}" @class(['shrink-0 rounded-xl px-4 py-3 text-sm font-medium transition-colors hover:bg-slate-800 hover:text-white', 'bg-brand/10 text-brand' => request()->routeIs($routeName) || request()->routeIs(str_replace('.index', '.*', $routeName)), 'text-slate-400' => !request()->routeIs($routeName)])>{{ $label }}</a>
|
||||
@endif
|
||||
@endforeach
|
||||
@if(auth()->user()->role === 'admin')
|
||||
<a href="{{ route('users.index') }}" @class(['shrink-0 rounded-xl px-4 py-3 text-sm font-medium hover:bg-slate-800', 'bg-brand/10 text-brand' => request()->routeIs('users.*'), 'text-slate-400' => !request()->routeIs('users.*')])>Users</a>
|
||||
@endif
|
||||
</nav>
|
||||
<div class="px-6 pb-5 text-xs text-slate-500 lg:absolute lg:bottom-0">Made for the HickLab3D workshop</div>
|
||||
</aside>
|
||||
<div class="min-w-0 flex-1 lg:pl-64">
|
||||
<header class="flex min-h-20 items-center justify-between gap-4 border-b border-slate-800 px-6 lg:px-10">
|
||||
<div><p class="text-xs font-semibold uppercase tracking-[.2em] text-brand">Workshop dashboard</p><h1 class="text-xl font-bold">@yield('title', 'Overview')</h1></div>
|
||||
<div class="flex items-center gap-4"><span class="hidden text-sm text-slate-400 sm:inline">{{ auth()->user()->username }}</span><form method="post" action="{{ route('logout') }}">@csrf<button class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-300 hover:border-brand hover:text-white">Sign out</button></form></div>
|
||||
</header>
|
||||
<main class="mx-auto flex max-w-7xl flex-col gap-7 px-6 py-8 lg:px-10">
|
||||
@if(session('status'))<div role="status" class="rounded-xl border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand">{{ session('status') }}</div>@endif
|
||||
@if($errors->any())<div role="alert" class="rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-300">{{ $errors->first() }}</div>@endif
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Add printer')
|
||||
@section('content')
|
||||
<form method="post" action="{{ route('printers.store') }}" class="grid max-w-2xl gap-5 rounded-2xl border border-slate-800 bg-slate-900 p-6">@csrf
|
||||
@foreach([['printerName','Printer name','Bambu X1 Carbon'],['printerIp','IP address','192.168.1.100'],['serialNumber','Serial number','Printer serial'],['mqttPassword','Access code','Printer access code']] as [$name,$label,$placeholder])
|
||||
<label class="grid gap-2 text-sm font-medium">{{ $label }}<input name="{{ $name }}" type="{{ $name === 'mqttPassword' ? 'password' : 'text' }}" value="{{ old($name) }}" placeholder="{{ $placeholder }}" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 outline-none focus:border-brand"></label>
|
||||
@endforeach
|
||||
<div class="flex gap-3"><button class="rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Save printer</button><a href="{{ route('printers.index') }}" class="rounded-xl border border-slate-700 px-5 py-3">Cancel</a></div>
|
||||
</form>
|
||||
@endsection
|
||||
@@ -0,0 +1,15 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Print farm')
|
||||
@section('content')
|
||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Production</p><h2 class="mt-2 text-2xl font-bold">BamBuddy printers</h2><p class="mt-1 text-slate-400">Live state, progress and temperatures from the workshop.</p></div><a href="{{ config('services.bambuddy.url') }}" target="_blank" rel="noopener noreferrer" class="rounded-xl border border-brand/40 px-4 py-3 text-sm font-semibold text-brand hover:bg-brand/10">Open BamBuddy ↗</a></div>
|
||||
@if($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endif
|
||||
<div class="grid gap-4 lg:grid-cols-2">@forelse($printers as $printer)
|
||||
@php $live = $printer['live'] ?? []; $progress = min(100, max(0, (float) ($live['progress'] ?? 0))); $printerErrors = is_array($live['hms_errors'] ?? null) ? $live['hms_errors'] : []; @endphp
|
||||
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><div class="flex justify-between gap-3"><div><h3 class="text-lg font-semibold">{{ $printer['name'] ?? 'Printer' }}</h3><p class="text-sm text-slate-400">{{ $printer['model'] ?? 'Bambu printer' }}</p></div><span @class(['h-fit rounded-full px-3 py-1 text-xs font-semibold', 'bg-brand/10 text-brand' => ($live['connected'] ?? false), 'bg-amber-500/10 text-amber-200' => ! ($live['connected'] ?? false)])>{{ $live['state'] ?? $printer['status'] ?? 'Unavailable' }}</span></div>
|
||||
<div class="mt-6 flex items-end justify-between gap-4"><div><p class="text-sm text-slate-400">Current job</p><p class="mt-1 max-w-sm truncate font-medium">{{ $live['current_print'] ?? $live['subtask_name'] ?? 'No active job' }}</p></div><strong class="text-2xl">{{ number_format($progress, 0) }}%</strong></div>
|
||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-800"><div class="h-full rounded-full bg-brand" style="width: {{ $progress }}%"></div></div>
|
||||
<dl class="mt-5 grid grid-cols-3 gap-3 text-sm"><div class="rounded-xl bg-slate-950 p-3"><dt class="text-slate-400">Nozzle</dt><dd class="mt-1 font-semibold">{{ isset($live['temperatures']['nozzle']) ? $live['temperatures']['nozzle'].'°C' : '—' }}</dd></div><div class="rounded-xl bg-slate-950 p-3"><dt class="text-slate-400">Bed</dt><dd class="mt-1 font-semibold">{{ isset($live['temperatures']['bed']) ? $live['temperatures']['bed'].'°C' : '—' }}</dd></div><div class="rounded-xl bg-slate-950 p-3"><dt class="text-slate-400">Remaining</dt><dd class="mt-1 font-semibold">{{ isset($live['remaining_time']) ? $live['remaining_time'].' min' : '—' }}</dd></div></dl>
|
||||
@if(count($printerErrors))<p class="mt-4 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-200">{{ count($printerErrors) }} printer alert{{ count($printerErrors) === 1 ? '' : 's' }} · {{ $printerErrors[0]['description'] ?? 'Open BamBuddy for details' }}</p>@endif
|
||||
</article>
|
||||
@empty<div class="rounded-2xl border border-dashed border-slate-700 p-10 text-slate-400">No printers returned by BamBuddy.</div>@endforelse</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,22 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Shipping')
|
||||
@section('content')
|
||||
<div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Dispatch</p><h2 class="mt-2 text-2xl font-bold">Parcel2Go</h2><p class="mt-1 text-slate-400">Compare services, arrange a collection and download labels.</p></div>
|
||||
@isset($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endisset
|
||||
@if($bookingPending)<section role="alert" class="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-6"><h3 class="font-semibold text-amber-200">Check Parcel2Go before retrying</h3><p class="mt-2 text-sm text-amber-100/80">The last booking request had no confirmed result. Check your account for the collection order, then reset this form.</p><form method="post" action="{{ route('shipping.reset-booking') }}" class="mt-4 flex flex-wrap items-center gap-3">@csrf<label class="text-sm"><input type="checkbox" name="checked_account" value="1" required> I checked the Parcel2Go account</label><button class="rounded-xl border border-amber-500/50 px-4 py-2 text-sm font-semibold text-amber-200">Reset booking form</button></form></section>@endif
|
||||
@if($bookingResult)
|
||||
@php $paymentUrl = $bookingResult['Links']['payment'] ?? null; $paymentHost = is_string($paymentUrl) ? parse_url($paymentUrl, PHP_URL_HOST) : null; @endphp
|
||||
<section class="rounded-2xl border border-brand/40 bg-brand/10 p-6"><h3 class="text-lg font-semibold">Collection order #{{ $bookingResult['OrderId'] ?? '—' }} created</h3><p class="mt-2 text-sm text-slate-300">Total: £{{ number_format((float) ($bookingResult['TotalPrice'] ?? 0), 2) }}. Payment and collection confirmation are completed with Parcel2Go.</p>@if($paymentHost && ($paymentHost === 'parcel2go.com' || str_ends_with($paymentHost, '.parcel2go.com')))<a href="{{ $paymentUrl }}" target="_blank" rel="noopener noreferrer" class="mt-4 inline-flex rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Complete payment ↗</a>@endif</section>
|
||||
@endif
|
||||
<div class="grid gap-4 md:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Account</p><p class="mt-4 text-xl font-semibold">{{ trim(($account['Forename'] ?? '').' '.($account['Surname'] ?? '')) ?: 'Unavailable' }}</p><p class="mt-2 text-sm text-slate-400">{{ $account['Email'] ?? '' }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Prepay balance</p><p class="mt-4 text-3xl font-bold">£{{ number_format((float) ($balance['Balance'] ?? 0), 2) }}</p></div></div>
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><div class="mb-5"><h3 class="text-lg font-semibold">1. Compare services</h3><p class="mt-1 text-sm text-slate-400">Enter a UK parcel. Dimensions are centimetres and weight is kilograms.</p></div><form method="post" action="{{ route('shipping.quote') }}" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">@csrf
|
||||
<label class="grid gap-2 text-sm">Collection postcode<input name="collection_postcode" value="{{ old('collection_postcode') }}" required maxlength="12" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label>
|
||||
<label class="grid gap-2 text-sm">Delivery postcode<input name="delivery_postcode" value="{{ old('delivery_postcode') }}" required maxlength="12" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label>
|
||||
@foreach([['weight','Weight (kg)','0.1'],['length','Length (cm)','1'],['width','Width (cm)','1'],['height','Height (cm)','1'],['value','Parcel value (£)','0.01']] as [$field,$label,$step])<label class="grid gap-2 text-sm">{{ $label }}<input type="number" name="{{ $field }}" value="{{ old($field) }}" min="0" step="{{ $step }}" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label>@endforeach
|
||||
<div class="flex items-end"><button class="w-full rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Compare services</button></div>
|
||||
</form></section>
|
||||
@if(count($quotes))<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">2. Choose a collection service</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">@foreach($quotes as $quote)@php $service = $quote['Service'] ?? []; @endphp<article class="rounded-xl border border-slate-700 bg-slate-950 p-4"><p class="font-semibold">{{ $service['Name'] ?? 'Courier service' }}</p><p class="mt-1 text-sm text-slate-400">{{ $service['CourierName'] ?? '' }} · {{ $service['CollectionType'] ?? 'Unknown' }}</p><p class="mt-3 text-2xl font-bold text-brand">{{ isset($quote['TotalPrice']) ? '£'.number_format((float) $quote['TotalPrice'], 2) : 'See details' }}</p>@if(($service['CollectionType'] ?? null) === 'Collection' && ! empty($service['Slug']))<form method="post" action="{{ route('shipping.collection-dates') }}" class="mt-4 grid gap-3">@csrf<input type="hidden" name="service" value="{{ $service['Slug'] }}"><label class="grid gap-1 text-xs text-slate-400">Collection street<input name="sender_street" value="{{ old('sender_street') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><label class="grid gap-1 text-xs text-slate-400">Collection town<input name="sender_town" value="{{ old('sender_town') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><button class="rounded-lg border border-brand/40 px-3 py-2 text-sm font-semibold text-brand">Check collection dates</button></form>@endif</article>@endforeach</div></section>@endif
|
||||
@if(count($collectionDates) && $selectedService)<section class="rounded-2xl border border-brand/30 bg-slate-900 p-6"><h3 class="text-lg font-semibold">3. Arrange collection</h3><p class="mt-1 text-sm text-slate-400">Creating the order does not take payment. Parcel2Go will provide a payment link.</p><form method="post" action="{{ route('shipping.book-collection') }}" class="mt-5 grid gap-5">@csrf<label class="grid max-w-md gap-2 text-sm">Collection date<select name="collection_date" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"><option value="">Choose a date</option>@foreach($collectionDates as $date)<option value="{{ $date['CollectionDate'] ?? '' }}">{{ isset($date['CollectionDate']) ? \Illuminate\Support\Carbon::parse($date['CollectionDate'])->format('D d M Y') : 'Available date' }}{{ ! empty($date['Surcharge']) ? ' · £'.number_format((float) $date['Surcharge'], 2).' surcharge' : '' }}</option>@endforeach</select></label>
|
||||
<div class="grid gap-5 lg:grid-cols-2"><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Sender and collection</legend>@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field, session('shipping_sender_location.'.$field)) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Recipient and delivery</legend>@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset></div><label class="grid max-w-xl gap-2 text-sm">Parcel contents<input name="contents" value="{{ old('contents') }}" required maxlength="200" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label><button class="w-fit rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Create unpaid order</button></form></section>@endif
|
||||
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900"><h3 class="p-6 text-lg font-semibold">Recent shipments and labels</h3><table class="w-full min-w-[750px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Order</th><th class="p-4">Courier</th><th class="p-4">Service</th><th class="p-4">Status</th><th class="p-4">Recipient</th><th class="p-4">Label</th></tr></thead><tbody class="divide-y divide-slate-800">@forelse(($orders['Orders'] ?? []) as $order)<tr><td class="p-4">{{ $order['OrderLineId'] ?? '—' }}</td><td class="p-4">{{ $order['Courier'] ?? '—' }}</td><td class="p-4">{{ $order['Service'] ?? '—' }}</td><td class="p-4">{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</td><td class="p-4">{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}</td><td class="p-4">@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))<a class="font-semibold text-brand hover:underline" href="{{ route('shipping.label', $order['OrderLineId']) }}">Download PDF</a>@else<span class="text-slate-500">Unavailable</span>@endif</td></tr>@empty<tr><td colspan="6" class="p-6 text-slate-400">No shipments available.</td></tr>@endforelse</tbody></table></section>
|
||||
@endsection
|
||||
@@ -0,0 +1,35 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Users')
|
||||
@section('content')
|
||||
<div><h2 class="text-2xl font-bold">Team access</h2><p class="mt-1 text-slate-400">Create accounts and manage dashboard permissions.</p></div>
|
||||
<form method="post" action="{{ route('users.store') }}" class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900 p-6 md:grid-cols-2">@csrf
|
||||
<h3 class="text-lg font-semibold md:col-span-2">Add a user</h3>
|
||||
<input name="username" placeholder="Username" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3">
|
||||
<input name="email" type="email" placeholder="Email address" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3">
|
||||
<input name="password" type="password" minlength="12" placeholder="Password (12+ characters)" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3">
|
||||
<select name="role" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"><option value="user">User</option><option value="admin">Admin</option></select>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-300"><input type="checkbox" name="alertEmails" value="1"> Send price alerts</label>
|
||||
<div><button class="rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Create user</button></div>
|
||||
</form>
|
||||
<div class="grid gap-3">@foreach($users as $user)
|
||||
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div><p class="font-semibold">{{ $user->username }} <span class="ml-2 rounded-full bg-slate-800 px-2 py-1 text-xs text-slate-300">{{ $user->role }}</span></p><p class="mt-1 text-sm text-slate-400">{{ $user->email }} · {{ $user->disabled ? 'Disabled' : 'Active' }}</p></div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form method="post" action="{{ route('users.update', $user) }}">@csrf @method('PATCH')<input type="hidden" name="disabled" value="{{ $user->disabled ? 0 : 1 }}"><button class="rounded-lg border border-slate-700 px-3 py-2 text-sm">{{ $user->disabled ? 'Enable' : 'Disable' }}</button></form>
|
||||
<form method="post" action="{{ route('users.destroy', $user) }}" onsubmit="return confirm('Delete this account?')">@csrf @method('DELETE')<button class="rounded-lg border border-rose-500/40 px-3 py-2 text-sm text-rose-300">Delete</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<details class="mt-4 border-t border-slate-800 pt-4"><summary class="cursor-pointer text-sm text-brand">Edit account or reset password</summary>
|
||||
<form method="post" action="{{ route('users.update', $user) }}" class="mt-4 grid gap-3 md:grid-cols-2">@csrf @method('PATCH')
|
||||
<label class="grid gap-1 text-sm text-slate-400">Email<input name="email" type="email" value="{{ $user->email }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100"></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">Role<select name="role" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100"><option value="user" @selected($user->role === 'user')>User</option><option value="admin" @selected($user->role === 'admin')>Admin</option></select></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">New password (leave blank to keep current)<input name="password" type="password" minlength="12" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100"></label>
|
||||
<div class="flex items-end"><input type="hidden" name="alertEmails" value="0"><label class="flex items-center gap-2 text-sm text-slate-300"><input type="checkbox" name="alertEmails" value="1" @checked($user->alertEmails)> Send price alerts</label></div>
|
||||
<div class="md:col-span-2"><button class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-slate-950">Save changes</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</article>
|
||||
@endforeach</div>
|
||||
{{ $users->links() }}
|
||||
@endsection
|
||||
Reference in New Issue
Block a user