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 Sales, production, materials and dispatch in one place.Good to see you, {{ auth()->user()->username }}.
 }})
Sales channel
Orders and listings →
@endif -Production
Live print farm →
-Materials
Filament prices →
- @if(auth()->user()->role === 'admin')Dispatch
Quotes and shipments →
@endif -Workshop tools
Status and controls →
- @if($userCount !== null)Team
Manage access →
@endif +Workshop overview
+What needs attention across the shop and workshop.
+Updated when you open this page
{{ $filament->filamentName }}
{{ $filament->brand ?: 'Unknown brand' }} · {{ $filament->material ?: 'Material pending' }}
No filament is being tracked yet.
@endforelseAwaiting 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
+ + @endifPlaced in the last 14 days among the latest 20 unshipped Etsy receipts
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.') }}
Tracked filaments by material
No materials tracked yet
Open filament tracker →Print farm: {{ $printerStatus }}
Dryer: {{ $dryerState }}
{{ $printerStatus }}
{{ $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).'%' : '—' }}
{{ $printerStatus === 'Live from BamBuddy' ? 'No printers returned by BamBuddy.' : $printerStatus }}
+ @endforelse +Latest paid Etsy receipts awaiting dispatch
#{{ $receipt['receipt_id'] ?? '—' }} · {{ $receipt['name'] ?? 'Buyer' }}
{{ isset($receipt['create_timestamp']) ? \Illuminate\Support\Carbon::createFromTimestamp((int) $receipt['create_timestamp'])->format('d M Y') : 'Date unavailable' }}
{{ $etsyStatus === 'Not connected' ? 'Connect Etsy to see orders.' : ($etsyStatus === 'Temporarily unavailable' ? 'Etsy data is temporarily unavailable.' : 'No orders awaiting dispatch.') }}
+ @endforelse +Parcel2Go · {{ $shippingStatus }}
#{{ $shipment['OrderLineId'] ?? '—' }} · {{ $shipment['Courier'] ?? 'Courier' }}
{{ $shipment['DeliveryAddress']['ContactName'] ?? 'Recipient unavailable' }}
{{ $shippingStatus === 'Live account' ? 'No recent shipments.' : $shippingStatus }}
@endforelse +Most recently tracked materials
{{ $filament->filamentName }}
{{ $filament->brand ?: 'Unknown brand' }} · {{ $filament->material ?: 'Material pending' }}
No filament is being tracked yet.
@endforelse +{{ $dryerState }}