From 1c72937f9a23bfd1d8fa066060e80a8869048da3 Mon Sep 17 00:00:00 2001 From: Hickmeister <35031453+Hickmeister@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:29:34 +0100 Subject: [PATCH] Build live HickLab3D overview dashboard --- app/Http/Controllers/DashboardController.php | 112 +++++++++++++++- package-lock.json | 8 ++ package.json | 1 + resources/js/app.js | 64 +++++++++- resources/views/dashboard.blade.php | 128 +++++++++++++++++-- tests/Feature/DashboardAccessTest.php | 92 +++++++++++++ 6 files changed, 386 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 7e665ea..6be1bb9 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -2,19 +2,121 @@ namespace App\Http\Controllers; +use App\Models\EtsyConnection; 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\Support\Carbon; use Illuminate\View\View; +use Throwable; 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', [ - 'filamentCount' => Filament::count(), - 'userCount' => $request->user()->role === 'admin' ? User::count() : null, - 'recentFilaments' => Filament::with(['prices' => fn ($query) => $query->orderByDesc('recordedAt')->limit(1)])->orderByDesc('id')->limit(5)->get(), + 'isAdmin' => $isAdmin, + 'filamentCount' => $filamentCount, + '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, + ], ]); } } diff --git a/package-lock.json b/package-lock.json index d6eaade..80824f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "apexcharts": "^5.16.0", "concurrently": "^10.0.3", "laravel-vite-plugin": "^3.1", "tailwindcss": "^4.0.0", @@ -733,6 +734,13 @@ "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": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", diff --git a/package.json b/package.json index 13ec885..91651bf 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "apexcharts": "^5.16.0", "concurrently": "^10.0.3", "laravel-vite-plugin": "^3.1", "tailwindcss": "^4.0.0", diff --git a/resources/js/app.js b/resources/js/app.js index 8337712..9dd3b80 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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(); + } + }); + }); +} diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 16f7225..f76b37e 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,19 +1,121 @@ @extends('layouts.app') @section('title', 'Overview') @section('content') -
-

HickLab3D workshop

Good to see you, {{ auth()->user()->username }}.

Sales, production, materials and dispatch in one place.

-
HickLab3D monogram
-
-
- @if(auth()->user()->role === 'admin')

Sales channel

Etsy shop

Orders and listings →

@endif -

Production

BamBuddy

Live print farm →

-

Materials

{{ $filamentCount }} tracked

Filament prices →

- @if(auth()->user()->role === 'admin')

Dispatch

Parcel2Go

Quotes and shipments →

@endif -

Workshop tools

Filament dryer

Status and controls →

- @if($userCount !== null)

Team

{{ $userCount }} accounts

Manage access →

@endif +
+
+

Workshop overview

+

Good to see you, {{ auth()->user()->username }}.

+

What needs attention across the shop and workshop.

+
+

Updated when you open this page

-

Recently tracked filament

View all →
-
@forelse($recentFilaments as $filament)

{{ $filament->filamentName }}

{{ $filament->brand ?: 'Unknown brand' }} · {{ $filament->material ?: 'Material pending' }}

{{ $filament->prices->first() ? '£'.number_format($filament->prices->first()->price, 2) : 'Pending' }}
@empty

No filament is being tracked yet.

@endforelse
+ +
+ @if($isAdmin) + +

Awaiting dispatch

↗
+

{{ $etsyConnected ? $awaitingDispatch : '—' }}

+

{{ $etsyStatus }} · Latest 20 Etsy receipts

+
+ @endif + +

Printing now

↗
+

{{ $printerStatus === 'Live from BamBuddy' ? $printingCount : '—' }}/ {{ $printerStatus === 'Live from BamBuddy' ? count($printers) : '—' }}

+

{{ $printerStatus }}

+
+ +

Tracked materials

↗
+

{{ $filamentCount }}

+

{{ count($materials) }} material types in your tracker

+
+ @if($isAdmin) + +

Active listings

↗
+

{{ $listingCount ?? '—' }}

+

{{ $etsyStatus }}

+
+ @else + +

Filament dryer

↗
+

{{ $dryerState }}

+

Current workshop state

+
+ @endif
+ +
+ @if($isAdmin) +
+
+

Orders awaiting dispatch

Placed in the last 14 days among the latest 20 unshipped Etsy receipts

+ View Etsy → +
+ @if($etsyConnected && array_sum($orderChart['series']) > 0) +
+ @else +

No recent dispatch orders to chart

{{ $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.') }}

+ @endif +
+ @endif +
+

Material mix

Tracked filaments by material

+ @if($filamentCount) +
+ @else +

No materials tracked yet

Open filament tracker →
+ @endif +
+ @unless($isAdmin) +

Workshop status

Print farm: {{ $printerStatus }}

Dryer: {{ $dryerState }}

+ @endunless +
+ +
+
+

Print farm

{{ $printerStatus }}

View all →
+
+ @forelse(array_slice($printers, 0, 4) as $printer) + @php $live = $printer['live'] ?? []; @endphp +

{{ $printer['name'] ?? 'Printer' }}

{{ $live['current_print'] ?? $live['subtask_name'] ?? 'No active job' }}

in_array(strtoupper((string) ($live['state'] ?? '')), ['RUNNING', 'PRINTING']), 'text-slate-400' => ! in_array(strtoupper((string) ($live['state'] ?? '')), ['RUNNING', 'PRINTING'])])>{{ $live['state'] ?? $printer['status'] ?? 'Unavailable' }}

{{ isset($live['progress']) ? number_format((float) $live['progress'], 0).'%' : '—' }}

+ @empty +

{{ $printerStatus === 'Live from BamBuddy' ? 'No printers returned by BamBuddy.' : $printerStatus }}

+ @endforelse +
+
+ @if($isAdmin) +
+

Orders to pack

Latest paid Etsy receipts awaiting dispatch

View all →
+
+ @forelse($receipts as $receipt) +

#{{ $receipt['receipt_id'] ?? '—' }} · {{ $receipt['name'] ?? 'Buyer' }}

{{ isset($receipt['create_timestamp']) ? \Illuminate\Support\Carbon::createFromTimestamp((int) $receipt['create_timestamp'])->format('d M Y') : 'Date unavailable' }}

Pack & send
+ @empty +

{{ $etsyStatus === 'Not connected' ? 'Connect Etsy to see orders.' : ($etsyStatus === 'Temporarily unavailable' ? 'Etsy data is temporarily unavailable.' : 'No orders awaiting dispatch.') }}

+ @endforelse +
+
+ @endif +
+ +
+ @if($isAdmin) +
+

Recent shipments

Parcel2Go · {{ $shippingStatus }}

Labels & collections →
+
+ @forelse($shipments as $shipment) +

#{{ $shipment['OrderLineId'] ?? '—' }} · {{ $shipment['Courier'] ?? 'Courier' }}

{{ $shipment['DeliveryAddress']['ContactName'] ?? 'Recipient unavailable' }}

{{ ($shipment['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}
+ @empty

{{ $shippingStatus === 'Live account' ? 'No recent shipments.' : $shippingStatus }}

@endforelse +
+
+ @endif +
+

Filament tracker

Most recently tracked materials

View all →
+
+ @forelse($recentFilaments as $filament) +

{{ $filament->filamentName }}

{{ $filament->brand ?: 'Unknown brand' }} · {{ $filament->material ?: 'Material pending' }}

{{ $filament->prices->first() ? '£'.number_format($filament->prices->first()->price, 2) : 'Pending' }}
+ @empty

No filament is being tracked yet.

@endforelse +
+
+
+ +

Dryer status

{{ $dryerState }}

Open controls →
@endsection diff --git a/tests/Feature/DashboardAccessTest.php b/tests/Feature/DashboardAccessTest.php index 3c2b3a5..1f4d882 100644 --- a/tests/Feature/DashboardAccessTest.php +++ b/tests/Feature/DashboardAccessTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature; +use App\Models\EtsyConnection; +use App\Models\Filament; use App\Models\Printer; use App\Models\User; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; @@ -25,6 +27,96 @@ class DashboardAccessTest extends TestCase $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 { User::factory()->create(['username' => 'disabled-user', 'disabled' => true, 'password' => 'correct-password']);