Build live HickLab3D overview dashboard
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 24s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 24s
This commit is contained in:
@@ -2,19 +2,121 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\EtsyConnection;
|
||||||
use App\Models\Filament;
|
use App\Models\Filament;
|
||||||
use App\Models\User;
|
use App\Services\Bambuddy;
|
||||||
|
use App\Services\DryerClient;
|
||||||
|
use App\Services\Etsy;
|
||||||
|
use App\Services\Parcel2Go;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request): View
|
public function __invoke(Request $request, Etsy $etsy, Bambuddy $bambuddy, Parcel2Go $parcel2Go, DryerClient $dryer): View
|
||||||
{
|
{
|
||||||
|
$isAdmin = $request->user()->role === 'admin';
|
||||||
|
$filamentCount = Filament::count();
|
||||||
|
$recentFilaments = Filament::with(['prices' => fn ($query) => $query->orderByDesc('recordedAt')->limit(1)])
|
||||||
|
->orderByDesc('id')->limit(4)->get();
|
||||||
|
$materials = Filament::query()->select('material')->selectRaw('COUNT(*) as total')
|
||||||
|
->groupBy('material')->orderByDesc('total')->get()
|
||||||
|
->map(fn (Filament $filament): array => [
|
||||||
|
'label' => $filament->material ?: 'Unspecified',
|
||||||
|
'count' => (int) $filament->total,
|
||||||
|
])->all();
|
||||||
|
|
||||||
|
$etsyStatus = $isAdmin ? 'Not connected' : null;
|
||||||
|
$etsyConnected = false;
|
||||||
|
$receipts = [];
|
||||||
|
$listingCount = null;
|
||||||
|
$shippingStatus = $isAdmin ? 'Not configured' : null;
|
||||||
|
$shipments = [];
|
||||||
|
|
||||||
|
if ($isAdmin && ($connection = EtsyConnection::first()) && $etsy->configured()) {
|
||||||
|
try {
|
||||||
|
$receipts = $etsy->receipts($connection)['results'] ?? [];
|
||||||
|
$etsyStatus = 'Live · '.$connection->shop_name;
|
||||||
|
$etsyConnected = true;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
$etsyStatus = 'Temporarily unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$listings = $etsy->listings($connection);
|
||||||
|
$listingCount = (int) ($listings['count'] ?? count($listings['results'] ?? []));
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isAdmin && filled(config('services.parcel2go.client_id')) && filled(config('services.parcel2go.client_secret'))) {
|
||||||
|
try {
|
||||||
|
$shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4);
|
||||||
|
$shippingStatus = 'Live account';
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
$shippingStatus = 'Temporarily unavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$printers = [];
|
||||||
|
$printerStatus = 'Not configured';
|
||||||
|
if ($bambuddy->configured()) {
|
||||||
|
try {
|
||||||
|
$printers = $bambuddy->printers();
|
||||||
|
$printerStatus = 'Live from BamBuddy';
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
$printerStatus = 'Temporarily unavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$printingCount = collect($printers)->filter(fn (array $printer): bool => in_array(
|
||||||
|
strtoupper((string) ($printer['live']['state'] ?? $printer['status'] ?? '')),
|
||||||
|
['RUNNING', 'PRINTING'],
|
||||||
|
true,
|
||||||
|
))->count();
|
||||||
|
|
||||||
|
$dryerState = 'Not configured';
|
||||||
|
if (filled(config('services.dryer.api_key'))) {
|
||||||
|
try {
|
||||||
|
$dryerState = ucfirst((string) ($dryer->status()['state'] ?? 'Unknown'));
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
$dryerState = 'Unavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = collect(range(13, 0))->map(fn (int $offset): Carbon => now()->startOfDay()->subDays($offset));
|
||||||
|
$orderTrend = $days->map(fn (Carbon $day): int => collect($receipts)->filter(
|
||||||
|
fn (array $receipt): bool => isset($receipt['create_timestamp'])
|
||||||
|
&& Carbon::createFromTimestamp((int) $receipt['create_timestamp'])->isSameDay($day),
|
||||||
|
)->count())->all();
|
||||||
|
|
||||||
return view('dashboard', [
|
return view('dashboard', [
|
||||||
'filamentCount' => Filament::count(),
|
'isAdmin' => $isAdmin,
|
||||||
'userCount' => $request->user()->role === 'admin' ? User::count() : null,
|
'filamentCount' => $filamentCount,
|
||||||
'recentFilaments' => Filament::with(['prices' => fn ($query) => $query->orderByDesc('recordedAt')->limit(1)])->orderByDesc('id')->limit(5)->get(),
|
'recentFilaments' => $recentFilaments,
|
||||||
|
'materials' => $materials,
|
||||||
|
'receipts' => array_slice($receipts, 0, 5),
|
||||||
|
'awaitingDispatch' => count($receipts),
|
||||||
|
'listingCount' => $listingCount,
|
||||||
|
'etsyStatus' => $etsyStatus,
|
||||||
|
'etsyConnected' => $etsyConnected,
|
||||||
|
'shipments' => $shipments,
|
||||||
|
'shippingStatus' => $shippingStatus,
|
||||||
|
'printers' => $printers,
|
||||||
|
'printerStatus' => $printerStatus,
|
||||||
|
'printingCount' => $printingCount,
|
||||||
|
'dryerState' => $dryerState,
|
||||||
|
'orderChart' => [
|
||||||
|
'labels' => $days->map(fn (Carbon $day): string => $day->format('d M'))->all(),
|
||||||
|
'series' => $orderTrend,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+8
@@ -6,6 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"apexcharts": "^5.16.0",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@@ -733,6 +734,13 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/apexcharts": {
|
||||||
|
"version": "5.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.16.0.tgz",
|
||||||
|
"integrity": "sha512-ULO3Gqrm0/26uwBcYz49u181uIp8Fy6BQQgg9QUDajs+JAs/EbXxiAmdc6BLy1JY29/JXroM7P0j0t2JPDApdg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "SEE LICENSE IN LICENSE"
|
||||||
|
},
|
||||||
"node_modules/auto-bind": {
|
"node_modules/auto-bind": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"apexcharts": "^5.16.0",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
|||||||
+63
-1
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,121 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
@section('title', 'Overview')
|
@section('title', 'Overview')
|
||||||
@section('content')
|
@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="flex flex-wrap items-end justify-between gap-4">
|
||||||
<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>
|
||||||
<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>
|
<p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Workshop overview</p>
|
||||||
</section>
|
<h2 class="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Good to see you, {{ auth()->user()->username }}.</h2>
|
||||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<p class="mt-2 text-sm text-slate-400">What needs attention across the shop and workshop.</p>
|
||||||
@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
|
</div>
|
||||||
<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>
|
<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>
|
||||||
<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>
|
</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>
|
</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
|
@endsection
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\EtsyConnection;
|
||||||
|
use App\Models\Filament;
|
||||||
use App\Models\Printer;
|
use App\Models\Printer;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||||
@@ -25,6 +27,96 @@ class DashboardAccessTest extends TestCase
|
|||||||
$this->actingAs($user)->get(route('dashboard'))->assertOk()->assertSee('HickLab3D')->assertSee('maker');
|
$this->actingAs($user)->get(route('dashboard'))->assertOk()->assertSee('HickLab3D')->assertSee('maker');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_administrator_sees_live_shop_and_workshop_overview(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake([
|
||||||
|
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => [
|
||||||
|
['receipt_id' => 77, 'name' => 'Buyer One', 'create_timestamp' => now()->timestamp],
|
||||||
|
]]),
|
||||||
|
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['count' => 12, 'results' => []]),
|
||||||
|
'https://3d.example.test/api/v1/printers' => Http::response([['id' => 1, 'name' => 'X1 Carbon']]),
|
||||||
|
'https://3d.example.test/api/v1/printers/1/status' => Http::response(['connected' => true, 'state' => 'RUNNING', 'progress' => 45]),
|
||||||
|
'https://auth.example.test/token' => Http::response(['access_token' => 'shipping-token']),
|
||||||
|
'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => [
|
||||||
|
['OrderLineId' => 91, 'Courier' => 'Evri', 'Tracking' => ['Delivered' => false]],
|
||||||
|
]]),
|
||||||
|
'https://dryer.example.test/api/v1/status' => Http::response(['state' => 'idle']),
|
||||||
|
]);
|
||||||
|
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret']);
|
||||||
|
config()->set('services.bambuddy', ['url' => 'https://3d.example.test', 'api_key' => 'key']);
|
||||||
|
config()->set('services.parcel2go', [
|
||||||
|
'client_id' => 'id', 'client_secret' => 'secret',
|
||||||
|
'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api',
|
||||||
|
]);
|
||||||
|
config()->set('services.dryer', ['url' => 'https://dryer.example.test/api/v1', 'api_key' => 'key']);
|
||||||
|
EtsyConnection::create(['shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_at' => now()->addHour()]);
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
Filament::create(['userId' => $admin->id, 'filamentName' => 'Blue PLA', 'amazonUrl' => 'https://amazon.co.uk/blue', 'material' => 'PLA']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->get(route('dashboard'))->assertOk()
|
||||||
|
->assertSee('Awaiting dispatch')->assertSee('#77')->assertSee('12')
|
||||||
|
->assertSee('X1 Carbon')->assertSee('RUNNING')->assertSee('Blue PLA')
|
||||||
|
->assertSee('#91')->assertSee('data-dashboard-chart="orders"', false)
|
||||||
|
->assertSee('data-dashboard-chart="materials"', false)
|
||||||
|
->assertViewHas('orderChart', fn (array $chart): bool => end($chart['series']) === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_regular_user_does_not_request_or_see_admin_shop_data(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
config()->set('services.bambuddy.api_key', null);
|
||||||
|
config()->set('services.dryer.api_key', null);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())->get(route('dashboard'))->assertOk()
|
||||||
|
->assertDontSee('Orders to pack')->assertDontSee('Recent shipments')
|
||||||
|
->assertDontSee('Awaiting dispatch')->assertSee('No materials tracked yet');
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_etsy_dispatch_stays_visible_when_listings_are_unavailable(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake([
|
||||||
|
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => [
|
||||||
|
['receipt_id' => 77, 'name' => 'Buyer One', 'create_timestamp' => now()->timestamp],
|
||||||
|
]]),
|
||||||
|
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response([], 503),
|
||||||
|
]);
|
||||||
|
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret']);
|
||||||
|
config()->set('services.bambuddy.api_key', null);
|
||||||
|
config()->set('services.parcel2go.client_id', null);
|
||||||
|
config()->set('services.dryer.api_key', null);
|
||||||
|
EtsyConnection::create(['shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_at' => now()->addHour()]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('dashboard'))
|
||||||
|
->assertOk()->assertSee('#77')->assertSee('data-dashboard-chart="orders"', false)
|
||||||
|
->assertViewHas('listingCount', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unavailable_integrations_do_not_make_overview_fail(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake([
|
||||||
|
'https://api.etsy.com/*' => Http::response([], 503),
|
||||||
|
'https://3d.example.test/*' => Http::response([], 503),
|
||||||
|
'https://auth.example.test/*' => Http::response([], 503),
|
||||||
|
'https://dryer.example.test/*' => Http::response([], 503),
|
||||||
|
]);
|
||||||
|
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret']);
|
||||||
|
config()->set('services.bambuddy', ['url' => 'https://3d.example.test', 'api_key' => 'key']);
|
||||||
|
config()->set('services.parcel2go', [
|
||||||
|
'client_id' => 'id', 'client_secret' => 'secret',
|
||||||
|
'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api',
|
||||||
|
]);
|
||||||
|
config()->set('services.dryer', ['url' => 'https://dryer.example.test/api/v1', 'api_key' => 'key']);
|
||||||
|
EtsyConnection::create(['shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_at' => now()->addHour()]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('dashboard'))
|
||||||
|
->assertOk()->assertSee('Temporarily unavailable')->assertSee('No materials tracked yet')
|
||||||
|
->assertDontSee('data-dashboard-chart="orders"', false);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_disabled_account_cannot_sign_in(): void
|
public function test_disabled_account_cannot_sign_in(): void
|
||||||
{
|
{
|
||||||
User::factory()->create(['username' => 'disabled-user', 'disabled' => true, 'password' => 'correct-password']);
|
User::factory()->create(['username' => 'disabled-user', 'disabled' => true, 'password' => 'correct-password']);
|
||||||
|
|||||||
Reference in New Issue
Block a user