Build live HickLab3D overview dashboard
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 24s

This commit is contained in:
Hickmeister
2026-10-10 19:29:34 +01:00
parent 88dcf5101b
commit 1c72937f9a
6 changed files with 386 additions and 19 deletions
+63 -1
View File
@@ -1 +1,63 @@
//
const chartBase = {
chart: {
background: 'transparent',
foreColor: '#b8bec4',
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
toolbar: { show: false },
zoom: { enabled: false },
},
theme: { mode: 'dark' },
grid: { borderColor: '#292c30', strokeDashArray: 4 },
tooltip: { theme: 'dark' },
noData: { text: 'No data yet' },
};
if (document.querySelector('[data-dashboard-chart]')) {
import('apexcharts').then(({ default: ApexCharts }) => {
document.querySelectorAll('[data-dashboard-chart]').forEach((element) => {
let data;
try {
data = JSON.parse(element.dataset.chart);
} catch (error) {
console.error('Dashboard chart data is invalid', error);
return;
}
const height = element.clientHeight || 256;
let options;
if (element.dataset.dashboardChart === 'orders') {
options = {
...chartBase,
chart: { ...chartBase.chart, type: 'area', height },
colors: ['#e3f504'],
series: [{ name: 'Awaiting dispatch', data: data.series }],
xaxis: { categories: data.labels, labels: { rotate: -45, hideOverlappingLabels: true } },
yaxis: { min: 0, forceNiceScale: true, labels: { formatter: (value) => Number.isInteger(value) ? value.toString() : '' } },
stroke: { curve: 'smooth', width: 3 },
fill: { type: 'gradient', gradient: { opacityFrom: 0.35, opacityTo: 0.02 } },
dataLabels: { enabled: false },
markers: { size: 3 },
legend: { show: false },
};
} else if (element.dataset.dashboardChart === 'materials') {
options = {
...chartBase,
chart: { ...chartBase.chart, type: 'donut', height },
labels: data.map((item) => item.label),
series: data.map((item) => item.count),
colors: ['#e3f504', '#7dd3a3', '#8b9df0', '#f0a46b', '#cc9df0', '#66cbd1', '#f17fa5'],
legend: { position: 'bottom', fontSize: '12px', labels: { colors: '#b8bec4' } },
plotOptions: { pie: { donut: { size: '68%', labels: { show: true, total: { show: true, label: 'Spools' } } } } },
dataLabels: { enabled: false },
stroke: { colors: ['#17191b'], width: 2 },
};
}
if (options) {
new ApexCharts(element, options).render();
}
});
});
}
+115 -13
View File
@@ -1,19 +1,121 @@
@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 class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Workshop overview</p>
<h2 class="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Good to see you, {{ auth()->user()->username }}.</h2>
<p class="mt-2 text-sm text-slate-400">What needs attention across the shop and workshop.</p>
</div>
<p class="rounded-full border border-slate-700 px-3 py-1.5 text-xs text-slate-400">Updated when you open this page</p>
</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 class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="Key metrics">
@if($isAdmin)
<a href="{{ route('etsy.index') }}" class="group rounded-2xl border border-slate-800 bg-slate-900 p-5 transition hover:border-brand/50">
<div class="flex items-center justify-between"><p class="text-sm font-medium text-slate-400">Awaiting dispatch</p><span class="text-brand">↗</span></div>
<p class="mt-4 text-3xl font-bold tabular-nums">{{ $etsyConnected ? $awaitingDispatch : '—' }}</p>
<p class="mt-2 truncate text-xs text-slate-500">{{ $etsyStatus }} · Latest 20 Etsy receipts</p>
</a>
@endif
<a href="{{ route('printers.index') }}" class="group rounded-2xl border border-slate-800 bg-slate-900 p-5 transition hover:border-brand/50">
<div class="flex items-center justify-between"><p class="text-sm font-medium text-slate-400">Printing now</p><span class="text-brand">↗</span></div>
<p class="mt-4 text-3xl font-bold tabular-nums">{{ $printerStatus === 'Live from BamBuddy' ? $printingCount : '—' }}<span class="ml-2 text-base font-medium text-slate-500">/ {{ $printerStatus === 'Live from BamBuddy' ? count($printers) : '—' }}</span></p>
<p class="mt-2 text-xs text-slate-500">{{ $printerStatus }}</p>
</a>
<a href="{{ route('filaments.index') }}" class="group rounded-2xl border border-slate-800 bg-slate-900 p-5 transition hover:border-brand/50">
<div class="flex items-center justify-between"><p class="text-sm font-medium text-slate-400">Tracked materials</p><span class="text-brand">↗</span></div>
<p class="mt-4 text-3xl font-bold tabular-nums">{{ $filamentCount }}</p>
<p class="mt-2 text-xs text-slate-500">{{ count($materials) }} material types in your tracker</p>
</a>
@if($isAdmin)
<a href="{{ route('etsy.index') }}" class="group rounded-2xl border border-slate-800 bg-slate-900 p-5 transition hover:border-brand/50">
<div class="flex items-center justify-between"><p class="text-sm font-medium text-slate-400">Active listings</p><span class="text-brand">↗</span></div>
<p class="mt-4 text-3xl font-bold tabular-nums">{{ $listingCount ?? '—' }}</p>
<p class="mt-2 text-xs text-slate-500">{{ $etsyStatus }}</p>
</a>
@else
<a href="{{ route('dryer') }}" class="group rounded-2xl border border-slate-800 bg-slate-900 p-5 transition hover:border-brand/50">
<div class="flex items-center justify-between"><p class="text-sm font-medium text-slate-400">Filament dryer</p><span class="text-brand">↗</span></div>
<p class="mt-4 text-2xl font-bold">{{ $dryerState }}</p>
<p class="mt-2 text-xs text-slate-500">Current workshop state</p>
</a>
@endif
</section>
<div class="grid gap-4 xl:grid-cols-3">
@if($isAdmin)
<section class="min-w-0 rounded-2xl border border-slate-800 bg-slate-900 xl:col-span-2">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-slate-800 px-5 py-5 sm:px-6">
<div><h3 class="font-semibold">Orders awaiting dispatch</h3><p class="mt-1 text-xs text-slate-400">Placed in the last 14 days among the latest 20 unshipped Etsy receipts</p></div>
<a href="{{ route('etsy.index') }}" class="text-sm font-semibold text-brand hover:underline">View Etsy →</a>
</div>
@if($etsyConnected && array_sum($orderChart['series']) > 0)
<div class="p-5 sm:p-6"><div class="h-64 w-full" data-dashboard-chart="orders" data-chart="{{ json_encode($orderChart) }}" role="img" aria-label="Etsy orders awaiting dispatch by placement date"></div></div>
@else
<div class="flex min-h-64 flex-col items-center justify-center gap-2 px-6 text-center"><p class="font-medium">No recent dispatch orders to chart</p><p class="max-w-sm text-sm text-slate-400">{{ $etsyStatus === 'Not connected' ? 'Connect Etsy to see the current dispatch queue.' : ($etsyStatus === 'Temporarily unavailable' ? 'Etsy data is temporarily unavailable.' : 'Paid orders awaiting dispatch will appear here.') }}</p></div>
@endif
</section>
@endif
<section class="min-w-0 rounded-2xl border border-slate-800 bg-slate-900 {{ $isAdmin ? '' : 'xl:col-span-2' }}">
<div class="border-b border-slate-800 px-5 py-5 sm:px-6"><h3 class="font-semibold">Material mix</h3><p class="mt-1 text-xs text-slate-400">Tracked filaments by material</p></div>
@if($filamentCount)
<div class="p-5 sm:p-6"><div class="h-64 w-full" data-dashboard-chart="materials" data-chart="{{ json_encode($materials) }}" role="img" aria-label="Tracked filaments by material"></div></div>
@else
<div class="flex min-h-64 flex-col items-center justify-center gap-2 px-6 text-center"><p class="font-medium">No materials tracked yet</p><a href="{{ route('filaments.index') }}" class="text-sm text-brand hover:underline">Open filament tracker →</a></div>
@endif
</section>
@unless($isAdmin)
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-5 sm:p-6"><h3 class="font-semibold">Workshop status</h3><p class="mt-4 text-sm text-slate-400">Print farm: {{ $printerStatus }}</p><p class="mt-2 text-sm text-slate-400">Dryer: {{ $dryerState }}</p></section>
@endunless
</div>
<div class="grid gap-4 xl:grid-cols-2">
<section class="rounded-2xl border border-slate-800 bg-slate-900">
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 sm:px-6"><div><h3 class="font-semibold">Print farm</h3><p class="mt-1 text-xs text-slate-400">{{ $printerStatus }}</p></div><a href="{{ route('printers.index') }}" class="text-sm font-semibold text-brand hover:underline">View all →</a></div>
<div class="divide-y divide-slate-800 px-5 sm:px-6">
@forelse(array_slice($printers, 0, 4) as $printer)
@php $live = $printer['live'] ?? []; @endphp
<div class="flex items-center justify-between gap-4 py-4"><div class="min-w-0"><p class="truncate font-medium">{{ $printer['name'] ?? 'Printer' }}</p><p class="mt-1 truncate text-xs text-slate-400">{{ $live['current_print'] ?? $live['subtask_name'] ?? 'No active job' }}</p></div><div class="text-right"><p @class(['text-sm font-semibold', 'text-brand' => in_array(strtoupper((string) ($live['state'] ?? '')), ['RUNNING', 'PRINTING']), 'text-slate-400' => ! in_array(strtoupper((string) ($live['state'] ?? '')), ['RUNNING', 'PRINTING'])])>{{ $live['state'] ?? $printer['status'] ?? 'Unavailable' }}</p><p class="mt-1 text-xs text-slate-500">{{ isset($live['progress']) ? number_format((float) $live['progress'], 0).'%' : '—' }}</p></div></div>
@empty
<p class="py-8 text-sm text-slate-400">{{ $printerStatus === 'Live from BamBuddy' ? 'No printers returned by BamBuddy.' : $printerStatus }}</p>
@endforelse
</div>
</section>
@if($isAdmin)
<section class="rounded-2xl border border-slate-800 bg-slate-900">
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 sm:px-6"><div><h3 class="font-semibold">Orders to pack</h3><p class="mt-1 text-xs text-slate-400">Latest paid Etsy receipts awaiting dispatch</p></div><a href="{{ route('etsy.index') }}" class="text-sm font-semibold text-brand hover:underline">View all →</a></div>
<div class="divide-y divide-slate-800 px-5 sm:px-6">
@forelse($receipts as $receipt)
<div class="flex items-center justify-between gap-4 py-4"><div class="min-w-0"><p class="truncate font-medium">#{{ $receipt['receipt_id'] ?? '—' }} · {{ $receipt['name'] ?? 'Buyer' }}</p><p class="mt-1 text-xs text-slate-400">{{ isset($receipt['create_timestamp']) ? \Illuminate\Support\Carbon::createFromTimestamp((int) $receipt['create_timestamp'])->format('d M Y') : 'Date unavailable' }}</p></div><span class="shrink-0 rounded-full bg-amber-400/10 px-2.5 py-1 text-xs font-medium text-amber-200">Pack & send</span></div>
@empty
<p class="py-8 text-sm text-slate-400">{{ $etsyStatus === 'Not connected' ? 'Connect Etsy to see orders.' : ($etsyStatus === 'Temporarily unavailable' ? 'Etsy data is temporarily unavailable.' : 'No orders awaiting dispatch.') }}</p>
@endforelse
</div>
</section>
@endif
</div>
<div class="grid gap-4 xl:grid-cols-2">
@if($isAdmin)
<section class="rounded-2xl border border-slate-800 bg-slate-900">
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 sm:px-6"><div><h3 class="font-semibold">Recent shipments</h3><p class="mt-1 text-xs text-slate-400">Parcel2Go · {{ $shippingStatus }}</p></div><a href="{{ route('shipping') }}" class="text-sm font-semibold text-brand hover:underline">Labels & collections →</a></div>
<div class="divide-y divide-slate-800 px-5 sm:px-6">
@forelse($shipments as $shipment)
<div class="flex items-center justify-between gap-4 py-4"><div class="min-w-0"><p class="truncate font-medium">#{{ $shipment['OrderLineId'] ?? '—' }} · {{ $shipment['Courier'] ?? 'Courier' }}</p><p class="mt-1 truncate text-xs text-slate-400">{{ $shipment['DeliveryAddress']['ContactName'] ?? 'Recipient unavailable' }}</p></div><span class="shrink-0 text-xs text-slate-400">{{ ($shipment['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</span></div>
@empty<p class="py-8 text-sm text-slate-400">{{ $shippingStatus === 'Live account' ? 'No recent shipments.' : $shippingStatus }}</p>@endforelse
</div>
</section>
@endif
<section class="rounded-2xl border border-slate-800 bg-slate-900">
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 sm:px-6"><div><h3 class="font-semibold">Filament tracker</h3><p class="mt-1 text-xs text-slate-400">Most recently tracked materials</p></div><a href="{{ route('filaments.index') }}" class="text-sm font-semibold text-brand hover:underline">View all →</a></div>
<div class="divide-y divide-slate-800 px-5 sm:px-6">
@forelse($recentFilaments as $filament)
<div class="flex items-center justify-between gap-4 py-4"><div class="min-w-0"><p class="truncate font-medium">{{ $filament->filamentName }}</p><p class="mt-1 text-xs text-slate-400">{{ $filament->brand ?: 'Unknown brand' }} · {{ $filament->material ?: 'Material pending' }}</p></div><span class="shrink-0 font-semibold tabular-nums">{{ $filament->prices->first() ? '£'.number_format($filament->prices->first()->price, 2) : 'Pending' }}</span></div>
@empty<p class="py-8 text-sm text-slate-400">No filament is being tracked yet.</p>@endforelse
</div>
</section>
</div>
<section class="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-slate-800 bg-slate-900 px-5 py-4 sm:px-6"><div><h3 class="font-semibold">Dryer status</h3><p class="mt-1 text-sm text-slate-400">{{ $dryerState }}</p></div><a href="{{ route('dryer') }}" class="text-sm font-semibold text-brand hover:underline">Open controls →</a></section>
@endsection