Remove Amazon filament price tracking
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 2m41s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 2m41s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# HickLab3D dashboard
|
||||
|
||||
Laravel 13, Tailwind CSS 4, and MySQL dashboard for the HickLab3D workshop. It includes user accounts, Bambu printer telemetry, Amazon filament price tracking, Parcel2Go orders, and ESP32 filament dryer control.
|
||||
Laravel 13, Tailwind CSS 4, and MySQL dashboard for the HickLab3D workshop. It includes user accounts, Etsy shop data, BamBuddy printer status, Parcel2Go orders, and filament dryer control.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -28,20 +28,12 @@ The admin command prompts for a password. Never pass it on the command line. For
|
||||
|
||||
The migrations use the legacy table and column names. If you point the app at the existing TechOdyssey database, back it up first. The migrations skip tables already present and create Laravel's session, cache, and job tables. Existing schema variations have not been verified against a database dump. Do not run `migrate:fresh` on a database with production data.
|
||||
|
||||
## Integrations and scheduled work
|
||||
## Integrations
|
||||
|
||||
- Parcel2Go: set `P2G_CLIENT_ID`, `P2G_CLIENT_SECRET`, and `P2G_API_AUTH_URL`.
|
||||
- Dryer: set `DRYER_URL` to the ESP32 gateway base URL. The browser talks to authenticated Laravel routes; Laravel forwards status and control requests.
|
||||
- Printers: set `MQTT_PORT`, `MQTT_USERNAME`, and `MQTT_VERIFY_TLS`. Each printer's access code is entered when adding it. `printers:collect` samples telemetry from each printer.
|
||||
- Filament: `filaments:refresh` reads tracked Amazon listings and sends price drop mail to enabled recipients. Configure Laravel's `MAIL_*` settings for delivery. Amazon may change or block its public product HTML, in which case a listing is skipped.
|
||||
|
||||
Run Laravel's scheduler every minute on the server:
|
||||
|
||||
```sh
|
||||
php artisan schedule:run
|
||||
```
|
||||
|
||||
The schedule collects printer telemetry every five minutes and refreshes filament prices hourly. You can run either command manually for diagnosis.
|
||||
- Etsy: add the app keystring and shared secret on the Settings page, then connect the shop with OAuth.
|
||||
- Printers: set `BAMBUDDY_URL` and `BAMBUDDY_API_KEY` for live print farm status.
|
||||
- Dryer: set `DRYER_URL` and `DRYER_API_KEY`. The browser talks to authenticated Laravel routes; Laravel forwards status and control requests.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Filament;
|
||||
use App\Models\FilamentPrice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
#[Signature('filaments:refresh')]
|
||||
#[Description('Collect current Amazon prices for tracked filament')]
|
||||
class RefreshFilamentPrices extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
$drops = [];
|
||||
foreach (Filament::cursor() as $filament) {
|
||||
$host = strtolower((string) parse_url($filament->amazonUrl, PHP_URL_HOST));
|
||||
if (! in_array($host, ['amazon.co.uk', 'www.amazon.co.uk', 'amazon.com', 'www.amazon.com'], true)) {
|
||||
$this->warn($filament->filamentName.': unsupported product host');
|
||||
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$html = Http::timeout(15)->withHeaders(['User-Agent' => 'Mozilla/5.0 HickLab3D/1.0'])
|
||||
->get($filament->amazonUrl)->throw()->body();
|
||||
if (! preg_match('/class="[^"]*a-price-whole[^"]*"[^>]*>([0-9,.]+)/', $html, $whole)
|
||||
|| ! preg_match('/class="[^"]*a-price-fraction[^"]*"[^>]*>([0-9]{2})/', $html, $fraction)) {
|
||||
$this->warn($filament->filamentName.': price unavailable');
|
||||
|
||||
continue;
|
||||
}
|
||||
$price = (float) (str_replace(',', '', $whole[1]).'.'.$fraction[1]);
|
||||
if ($price <= 0) {
|
||||
continue;
|
||||
}
|
||||
preg_match('/class="[^"]*savingsPercentage[^"]*"[^>]*>[^0-9]*([0-9]+)%/', $html, $discount);
|
||||
$previous = FilamentPrice::where('filamentId', $filament->id)->orderByDesc('recordedAt')->orderByDesc('id')->first();
|
||||
FilamentPrice::create([
|
||||
'filamentId' => $filament->id,
|
||||
'price' => $price,
|
||||
'currentDiscount' => ['discount' => ['value' => (int) ($discount[1] ?? 0), 'type' => 'percentage'], 'voucher' => ['value' => 0, 'type' => 'none']],
|
||||
]);
|
||||
if ($previous && $price < (float) $previous->price) {
|
||||
$drops[] = $filament->filamentName.': £'.number_format((float) $previous->price, 2).' → £'.number_format($price, 2).' ('.$filament->amazonUrl.')';
|
||||
}
|
||||
$this->line($filament->filamentName.': £'.number_format($price, 2));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->warn($filament->filamentName.': collection failed');
|
||||
}
|
||||
}
|
||||
if ($drops) {
|
||||
$body = "HickLab3D filament price drops\n\n".implode("\n", $drops);
|
||||
foreach (User::where('alertEmails', true)->where('disabled', false)->pluck('email') as $email) {
|
||||
Mail::raw($body, fn ($message) => $message->to($email)->subject('HickLab3D filament price drops'));
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\Filament;
|
||||
use App\Services\Bambuddy;
|
||||
use App\Services\DryerClient;
|
||||
use App\Services\Etsy;
|
||||
@@ -18,16 +17,6 @@ class DashboardController extends Controller
|
||||
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 = [];
|
||||
@@ -75,11 +64,25 @@ class DashboardController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$printingCount = collect($printers)->filter(fn (array $printer): bool => in_array(
|
||||
strtoupper((string) ($printer['live']['state'] ?? $printer['status'] ?? '')),
|
||||
['RUNNING', 'PRINTING'],
|
||||
true,
|
||||
))->count();
|
||||
$printerStates = collect($printers)->countBy(function (array $printer): string {
|
||||
$live = $printer['live'] ?? [];
|
||||
$state = strtoupper((string) ($live['state'] ?? $printer['status'] ?? ''));
|
||||
|
||||
if ($live === []) {
|
||||
return 'Unavailable';
|
||||
}
|
||||
if (($live['connected'] ?? false) === false) {
|
||||
return 'Offline';
|
||||
}
|
||||
if (in_array($state, ['RUNNING', 'PRINTING'], true)) {
|
||||
return 'Printing';
|
||||
}
|
||||
if (in_array($state, ['IDLE', 'READY', 'FINISH'], true)) {
|
||||
return 'Idle';
|
||||
}
|
||||
|
||||
return 'Other';
|
||||
});
|
||||
|
||||
$dryerState = 'Not configured';
|
||||
if (filled(config('services.dryer.api_key'))) {
|
||||
@@ -99,9 +102,6 @@ class DashboardController extends Controller
|
||||
|
||||
return view('dashboard', [
|
||||
'isAdmin' => $isAdmin,
|
||||
'filamentCount' => $filamentCount,
|
||||
'recentFilaments' => $recentFilaments,
|
||||
'materials' => $materials,
|
||||
'receipts' => array_slice($receipts, 0, 5),
|
||||
'awaitingDispatch' => count($receipts),
|
||||
'listingCount' => $listingCount,
|
||||
@@ -111,7 +111,11 @@ class DashboardController extends Controller
|
||||
'shippingStatus' => $shippingStatus,
|
||||
'printers' => $printers,
|
||||
'printerStatus' => $printerStatus,
|
||||
'printingCount' => $printingCount,
|
||||
'printingCount' => $printerStates->get('Printing', 0),
|
||||
'printerChart' => $printerStates->map(fn (int $count, string $state): array => [
|
||||
'label' => $state,
|
||||
'count' => $count,
|
||||
])->values()->all(),
|
||||
'dryerState' => $dryerState,
|
||||
'orderChart' => [
|
||||
'labels' => $days->map(fn (Carbon $day): string => $day->format('d M'))->all(),
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FilamentController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('filaments.index', ['filaments' => Filament::with(['prices' => fn ($query) => $query->orderByDesc('recordedAt')->limit(10)])->orderBy('filamentName')->paginate(20)]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
|
||||
return view('filaments.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$data = $request->validate([
|
||||
'filamentName' => ['required', 'string', 'max:255'],
|
||||
'amazonUrl' => ['required', 'url:http,https', 'max:2048'],
|
||||
'brand' => ['nullable', 'string', 'max:255'],
|
||||
'material' => ['nullable', 'string', 'max:255'],
|
||||
'color' => ['nullable', 'string', 'max:255'],
|
||||
'filamentWeight' => ['nullable', 'numeric', 'min:0'],
|
||||
'itemDiameter' => ['nullable', 'numeric', 'min:0'],
|
||||
]);
|
||||
$host = strtolower((string) parse_url($data['amazonUrl'], PHP_URL_HOST));
|
||||
if (! in_array($host, ['amazon.co.uk', 'www.amazon.co.uk', 'amazon.com', 'www.amazon.com'], true)) {
|
||||
return back()->withErrors(['amazonUrl' => 'Enter an Amazon UK or US product URL.'])->withInput();
|
||||
}
|
||||
Filament::create([...$data, 'userId' => $request->user()->id]);
|
||||
|
||||
return redirect()->route('filaments.index')->with('status', 'Filament added. Price collection runs on the schedule.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Filament $filament): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$filament->delete();
|
||||
|
||||
return redirect()->route('filaments.index')->with('status', 'Filament removed.');
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ class UserController extends Controller
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'role' => ['required', Rule::in(['admin', 'user'])],
|
||||
'password' => ['required', 'string', 'min:12'],
|
||||
'alertEmails' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
User::create($data);
|
||||
|
||||
@@ -39,7 +38,6 @@ class UserController extends Controller
|
||||
'email' => ['sometimes', 'email', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
'role' => ['sometimes', Rule::in(['admin', 'user'])],
|
||||
'disabled' => ['sometimes', 'boolean'],
|
||||
'alertEmails' => ['sometimes', 'boolean'],
|
||||
'password' => ['nullable', 'string', 'min:12'],
|
||||
]);
|
||||
if (empty($data['password'])) {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\FilamentFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Filament extends Model
|
||||
{
|
||||
/** @use HasFactory<FilamentFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'filamentTracker';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['userId', 'filamentName', 'amazonUrl', 'filamentWeight', 'brand', 'material', 'color', 'itemDiameter'];
|
||||
|
||||
public function prices(): HasMany
|
||||
{
|
||||
return $this->hasMany(FilamentPrice::class, 'filamentId');
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\FilamentPriceFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class FilamentPrice extends Model
|
||||
{
|
||||
/** @use HasFactory<FilamentPriceFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'filamentPriceHistory';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['filamentId', 'price', 'currentDiscount'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['currentDiscount' => 'array', 'recordedAt' => 'datetime', 'price' => 'decimal:2'];
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['username', 'email', 'role', 'disabled', 'alertEmails', 'password'])]
|
||||
#[Fillable(['username', 'email', 'role', 'disabled', 'password'])]
|
||||
#[Hidden(['password'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
@@ -29,7 +29,6 @@ class User extends Authenticatable
|
||||
return [
|
||||
'password' => 'hashed',
|
||||
'disabled' => 'boolean',
|
||||
'alertEmails' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Filament;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Filament>
|
||||
*/
|
||||
class FilamentFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\FilamentPrice;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<FilamentPrice>
|
||||
*/
|
||||
class FilamentPriceFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ class UserFactory extends Factory
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'role' => 'user',
|
||||
'disabled' => false,
|
||||
'alertEmails' => false,
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
];
|
||||
}
|
||||
|
||||
+3
-3
@@ -41,15 +41,15 @@ if (document.querySelector('[data-dashboard-chart]')) {
|
||||
markers: { size: 3 },
|
||||
legend: { show: false },
|
||||
};
|
||||
} else if (element.dataset.dashboardChart === 'materials') {
|
||||
} else if (element.dataset.dashboardChart === 'printers') {
|
||||
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'],
|
||||
colors: ['#e3f504', '#7dd3a3', '#f0a46b', '#f17fa5', '#8b9df0'],
|
||||
legend: { position: 'bottom', fontSize: '12px', labels: { colors: '#b8bec4' } },
|
||||
plotOptions: { pie: { donut: { size: '68%', labels: { show: true, total: { show: true, label: 'Spools' } } } } },
|
||||
plotOptions: { pie: { donut: { size: '68%', labels: { show: true, total: { show: true, label: 'Printers' } } } } },
|
||||
dataLabels: { enabled: false },
|
||||
stroke: { colors: ['#17191b'], width: 2 },
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<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="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="Key metrics">
|
||||
<section @class(['grid gap-4 sm:grid-cols-2', 'xl:grid-cols-4' => $isAdmin]) 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>
|
||||
@@ -23,24 +23,18 @@
|
||||
<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
|
||||
@endif
|
||||
<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">
|
||||
@@ -58,11 +52,11 @@
|
||||
</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>
|
||||
<div class="border-b border-slate-800 px-5 py-5 sm:px-6"><h3 class="font-semibold">Print farm status</h3><p class="mt-1 text-xs text-slate-400">Live printer states from BamBuddy</p></div>
|
||||
@if(count($printerChart))
|
||||
<div class="p-5 sm:p-6"><div class="h-64 w-full" data-dashboard-chart="printers" data-chart="{{ json_encode($printerChart) }}" role="img" aria-label="Printers by current state"></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>
|
||||
<div class="flex min-h-64 flex-col items-center justify-center gap-2 px-6 text-center"><p class="font-medium">No printer status to chart</p><a href="{{ route('printers.index') }}" class="text-sm text-brand hover:underline">Open print farm →</a></div>
|
||||
@endif
|
||||
</section>
|
||||
@unless($isAdmin)
|
||||
@@ -96,8 +90,8 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-2">
|
||||
@if($isAdmin)
|
||||
<div>
|
||||
<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">
|
||||
@@ -106,16 +100,7 @@
|
||||
@empty<p class="py-8 text-sm text-slate-400">{{ $shippingStatus === 'Live account' ? 'No recent shipments.' : $shippingStatus }}</p>@endforelse
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@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
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
@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
|
||||
@@ -1,10 +0,0 @@
|
||||
@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
|
||||
@@ -20,7 +20,7 @@
|
||||
<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
|
||||
@php $links = [['dashboard', 'Overview'], ['printers.index', 'Print farm'], ['etsy.index', 'Etsy'], ['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>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<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)
|
||||
@@ -25,7 +24,6 @@
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('filaments:refresh')->hourly()->withoutOverlapping(55);
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\DryerController;
|
||||
use App\Http\Controllers\EtsyController;
|
||||
use App\Http\Controllers\EtsySettingsController;
|
||||
use App\Http\Controllers\FilamentController;
|
||||
use App\Http\Controllers\PrinterController;
|
||||
use App\Http\Controllers\ShippingController;
|
||||
use App\Http\Controllers\UserController;
|
||||
@@ -23,7 +22,6 @@ Route::middleware(['auth', EnsureAccountActive::class])->group(function (): void
|
||||
Route::get('/', DashboardController::class)->name('dashboard');
|
||||
Route::get('/printers', BambuddyController::class)->name('printers.index');
|
||||
Route::delete('/printers/{printer}', [PrinterController::class, 'destroy'])->name('printers.destroy');
|
||||
Route::resource('filaments', FilamentController::class)->only(['index', 'create', 'store', 'destroy']);
|
||||
Route::get('/shipping', [ShippingController::class, 'index'])->name('shipping');
|
||||
Route::post('/shipping/quotes', [ShippingController::class, 'quote'])->name('shipping.quote');
|
||||
Route::post('/shipping/collections/dates', [ShippingController::class, 'collectionDates'])->name('shipping.collection-dates');
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\Filament;
|
||||
use App\Models\Printer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
@@ -52,13 +51,13 @@ class DashboardAccessTest extends TestCase
|
||||
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('X1 Carbon')->assertSee('RUNNING')->assertSee('Print farm status')
|
||||
->assertSee('#91')->assertSee('data-dashboard-chart="orders"', false)
|
||||
->assertSee('data-dashboard-chart="materials"', false)
|
||||
->assertSee('data-dashboard-chart="printers"', false)
|
||||
->assertViewHas('printerChart', [['label' => 'Printing', 'count' => 1]])
|
||||
->assertViewHas('orderChart', fn (array $chart): bool => end($chart['series']) === 1);
|
||||
}
|
||||
|
||||
@@ -70,7 +69,7 @@ class DashboardAccessTest extends TestCase
|
||||
|
||||
$this->actingAs(User::factory()->create())->get(route('dashboard'))->assertOk()
|
||||
->assertDontSee('Orders to pack')->assertDontSee('Recent shipments')
|
||||
->assertDontSee('Awaiting dispatch')->assertSee('No materials tracked yet');
|
||||
->assertDontSee('Awaiting dispatch')->assertSee('No printer status to chart');
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
@@ -113,7 +112,7 @@ class DashboardAccessTest extends TestCase
|
||||
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')
|
||||
->assertOk()->assertSee('Temporarily unavailable')->assertSee('No printer status to chart')
|
||||
->assertDontSee('data-dashboard-chart="orders"', false);
|
||||
}
|
||||
|
||||
@@ -126,41 +125,13 @@ class DashboardAccessTest extends TestCase
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_regular_user_cannot_create_filament(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->post(route('filaments.store'), [
|
||||
'filamentName' => 'PLA Black', 'amazonUrl' => 'https://www.amazon.co.uk/example',
|
||||
])->assertForbidden();
|
||||
$this->assertDatabaseCount('filamentTracker', 0);
|
||||
}
|
||||
|
||||
public function test_administrator_can_create_filament(): void
|
||||
public function test_removed_filament_tracker_routes_are_unavailable(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->post(route('filaments.store'), [
|
||||
'filamentName' => 'PLA Black', 'amazonUrl' => 'https://www.amazon.co.uk/example',
|
||||
])->assertRedirect(route('filaments.index'));
|
||||
$this->assertDatabaseHas('filamentTracker', ['filamentName' => 'PLA Black', 'userId' => $admin->id]);
|
||||
}
|
||||
|
||||
public function test_filament_tracking_rejects_non_amazon_urls(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->post(route('filaments.store'), [
|
||||
'filamentName' => 'PLA Black', 'amazonUrl' => 'https://example.test/product',
|
||||
])->assertSessionHasErrors('amazonUrl');
|
||||
$this->assertDatabaseCount('filamentTracker', 0);
|
||||
}
|
||||
|
||||
public function test_administrator_can_view_filament_list(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$this->actingAs($admin)->get(route('filaments.index'))->assertOk()->assertSee('Filament prices');
|
||||
$this->actingAs($admin)->get('/filaments')->assertNotFound();
|
||||
$this->actingAs($admin)->get('/filaments/create')->assertNotFound();
|
||||
$this->actingAs($admin)->post('/filaments', ['filamentName' => 'PLA Black'])->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_administrator_can_manage_user_without_replacing_password(): void
|
||||
@@ -169,9 +140,9 @@ class DashboardAccessTest extends TestCase
|
||||
$member = User::factory()->create(['password' => 'original-password']);
|
||||
|
||||
$this->actingAs($admin)->patch(route('users.update', $member), [
|
||||
'email' => 'new@example.test', 'role' => 'user', 'alertEmails' => 1, 'password' => '',
|
||||
'email' => 'new@example.test', 'role' => 'user', 'password' => '',
|
||||
])->assertRedirect();
|
||||
$this->assertDatabaseHas('users', ['id' => $member->id, 'email' => 'new@example.test', 'alertEmails' => 1]);
|
||||
$this->assertDatabaseHas('users', ['id' => $member->id, 'email' => 'new@example.test']);
|
||||
$this->assertTrue(Hash::check('original-password', $member->fresh()->password));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user