Files
HickLab3D_Dashboard/resources/views/settings/index.blade.php
T
hermes 960189d14b Add settings-driven Artisan scheduler registry with Etsy sync and token refresh jobs
Introduces App\Support\DashboardSchedule (modelled on LHITS_Dashboard's
pattern) as a single settings-driven registry for recurring Artisan jobs,
replacing the two hard-coded Schedule::command() lines in
routes/console.php.

Migrated unchanged:
- integrations_refresh (integrations:refresh), every_30_seconds, withoutOverlapping(5)
- alerts_scan (alerts:scan), every_30_seconds, withoutOverlapping(5)

New:
- etsy_sync (etsy:sync), every_15_minutes - forces a fresh pull of Etsy
  receipts/listings into the existing IntegrationCache entries, decoupled
  from the bundled integrations:refresh job so it can be tuned/disabled
  independently of Parcel2Go/BamBuddy/dryer refreshes.
- etsy_token_refresh (etsy:refresh-token), every_45_minutes - proactively
  refreshes the Etsy OAuth token (tokens last ~1hr) via a new public
  Etsy::refreshAccessToken() wrapper around the existing on-demand
  accessToken() refresh logic, so the connection survives quiet periods.

Frequency is persisted to storage/app/dashboard-schedules.json (no
app_settings table exists in this app, unlike LHITS's AppSetting-backed
store) and surfaced in a new 'Scheduler' section on the existing Settings
page (ScheduleSettingsController + routes), with per-job frequency
dropdowns and manual 'Run now' buttons, matching the page's existing
Tailwind conventions.

Tests: SchedulerTest (registry/frequency behavior), ScheduleSettingsTest
(settings UI + authorization), EtsySyncScheduledJobsTest (new commands,
Http::fake only, no live Etsy calls).
2026-11-07 17:38:11 +00:00

172 lines
18 KiB
PHP

@extends('layouts.app')
@section('title', 'Settings')
@section('content')
<div class="flex flex-wrap items-end justify-between gap-4">
<div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Workspace connections</p><h2 class="mt-2 text-2xl font-bold">Integration settings</h2><p class="mt-2 max-w-2xl text-sm text-slate-400">Manage the services behind your orders, shipping and workshop. Saved credentials are encrypted in the database.</p></div>
<span class="rounded-full border border-slate-700 px-3 py-1.5 text-xs text-slate-400">Administrators only</span>
</div>
<nav class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4" aria-label="Integration settings sections">
@foreach([
['etsy', 'Etsy shop', $configured && $shopConnected, $storedCredentials, 'Orders and listings'],
['parcel2go', 'Parcel2Go', $integrations['parcel2go']['configured'], $integrations['parcel2go']['stored'], 'Shipping and labels'],
['bambuddy', 'BamBuddy', $integrations['bambuddy']['configured'], $integrations['bambuddy']['stored'], 'Print farm'],
['dryer', 'Filament dryer', $integrations['dryer']['configured'], $integrations['dryer']['stored'], 'Drying controls'],
['scheduler', 'Scheduler', true, true, 'Job frequencies'],
] as [$section, $name, $isConfigured, $isStored, $description])
<a href="#{{ $section }}" class="group rounded-2xl border border-slate-800 bg-slate-900 p-4 transition-colors hover:border-brand/60 hover:bg-slate-800/70">
<span class="flex items-center justify-between gap-2"><strong class="text-sm text-white">{{ $name }}</strong><span aria-hidden="true" class="text-slate-500 group-hover:text-brand">↗</span></span>
<span class="mt-1 block text-xs text-slate-400">{{ $description }}</span>
<span @class(['mt-4 inline-flex rounded-full px-2.5 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $isConfigured, 'bg-amber-500/10 text-amber-200' => ! $isConfigured])>{{ $section === 'etsy' && $configured ? ($shopConnected ? 'Shop connected' : 'Connect shop') : ($isConfigured ? ($isStored ? 'Saved in dashboard' : 'Using server settings') : 'Setup needed') }}</span>
</a>
@endforeach
</nav>
<section id="etsy" class="scroll-mt-6 rounded-2xl border border-slate-800 bg-slate-900 p-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-lg font-semibold">Etsy shop</h3>
<p class="mt-1 text-sm text-slate-400">{{ $storedCredentials ? 'App credentials saved in dashboard.' : ($configured ? 'Using server app credentials.' : 'Add your Etsy app credentials.') }}</p>
</div>
<span @class(['rounded-full px-3 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $configured && $shopConnected, 'bg-amber-500/10 text-amber-200' => ! ($configured && $shopConnected)])>{{ $configured && $shopConnected ? 'Shop connected' : ($configured ? 'Connect shop' : 'Setup needed') }}</span>
</div>
<form method="post" action="{{ route('settings.etsy.update') }}" class="mt-6 grid gap-5 md:grid-cols-2">
@csrf
@method('PUT')
<label class="grid gap-2 text-sm text-slate-300">
App keystring
<input name="keystring" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $configured ? 'Saved — leave blank to keep' : 'Enter Etsy app keystring' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500" {{ $configured ? '' : 'required' }}>
</label>
<label class="grid gap-2 text-sm text-slate-300">
Shared secret
<input name="shared_secret" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $configured ? 'Saved — leave blank to keep' : 'Enter Etsy shared secret' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500" {{ $configured ? '' : 'required' }}>
</label>
<label class="grid gap-2 text-sm text-slate-300">
Shop name
<input name="shop_name" type="text" maxlength="255" value="{{ old('shop_name', $shopName) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
</label>
<details class="rounded-xl border border-slate-800 bg-slate-950/60 p-4 md:col-span-2" @if($errors->has('redirect_uri')) open @endif>
<summary class="cursor-pointer text-sm font-semibold text-slate-200">Advanced: OAuth callback</summary>
<label class="mt-4 grid gap-2 text-sm text-slate-300">Callback URL<input name="redirect_uri" type="url" maxlength="2048" value="{{ old('redirect_uri', $redirectUri) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<p class="mt-2 text-xs text-slate-400">Register this exact HTTPS URL in your Etsy developer app.</p>
</details>
<div class="md:col-span-2">
<button class="rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save Etsy settings</button>
@if($configured)<a href="{{ route('etsy.index') }}" class="ml-2 inline-flex rounded-xl border border-slate-700 px-5 py-3 text-sm font-semibold text-slate-200 hover:border-brand">{{ $shopConnected ? 'Open Etsy shop' : 'Connect Etsy shop' }}</a>@endif
</div>
</form>
<p class="mt-4 text-xs text-slate-500">Leave saved credentials blank to keep them. Saving imports server credentials if needed. Changing the app key disconnects the shop until you reconnect it.</p>
</section>
<div class="grid gap-5 xl:grid-cols-2">
<section id="parcel2go" class="scroll-mt-6 rounded-2xl border border-slate-800 bg-slate-900 p-6 xl:col-span-2">
<div class="flex flex-wrap items-start justify-between gap-3">
<div><h3 class="text-lg font-semibold">Parcel2Go</h3><p class="mt-1 text-sm text-slate-400">Client Credentials for shipping, account history and labels.</p></div>
<span @class(['rounded-full px-3 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['parcel2go']['configured'], 'bg-amber-500/10 text-amber-200' => ! $integrations['parcel2go']['configured']])>{{ $integrations['parcel2go']['configured'] ? ($integrations['parcel2go']['stored'] ? 'Saved in dashboard' : 'Using server settings') : 'Setup needed' }}</span>
</div>
<p class="mt-4 text-sm text-slate-400">Use the Client ID and secret from the Parcel2Go client that worked in your previous dashboard. Leave saved credentials blank to keep them.</p>
<form method="post" action="{{ route('settings.integrations.update', 'parcel2go') }}" class="mt-5 grid gap-4 lg:grid-cols-2">
@csrf @method('PUT')
<label class="grid gap-2 text-sm text-slate-300">Client ID<input name="client_id" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $integrations['parcel2go']['configured'] ? 'Saved — leave blank to keep' : 'Enter Client ID' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
<label class="grid gap-2 text-sm text-slate-300">Client secret<input name="client_secret" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $integrations['parcel2go']['configured'] ? 'Saved — leave blank to keep' : 'Enter client secret' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
<label class="grid gap-2 text-sm text-slate-300 lg:col-span-2">Collection postcode<input name="collection_postcode" maxlength="12" value="{{ old('collection_postcode', $integrations['parcel2go']['collection_postcode']) }}" placeholder="Used when quoting Etsy orders" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
<details class="rounded-xl border border-slate-800 bg-slate-950/60 p-4 lg:col-span-2" @if($errors->has('auth_url') || $errors->has('base_url') || $errors->has('scope') || $errors->has('profile_client_id')) open @endif>
<summary class="cursor-pointer text-sm font-semibold text-slate-200">Advanced: API endpoints and access</summary>
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<label class="grid gap-2 text-sm text-slate-300">OAuth token URL<input name="auth_url" type="url" maxlength="2048" value="{{ old('auth_url', $integrations['parcel2go']['auth_url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<label class="grid gap-2 text-sm text-slate-300">API base URL<input name="base_url" type="url" maxlength="2048" value="{{ old('base_url', $integrations['parcel2go']['base_url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<label class="grid gap-2 text-sm text-slate-300">Requested API scope<select name="scope" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">@foreach(['public-api' => 'public-api (recommended)', 'public-api my-profile' => 'public-api + my-profile', 'public-api payment' => 'public-api + payment', 'public-api payment my-profile' => 'public-api + payment + my-profile'] as $scope => $label)<option value="{{ $scope }}" @selected(old('scope', $integrations['parcel2go']['scope']) === $scope)>{{ $label }}</option>@endforeach</select></label>
<label class="grid gap-2 text-sm text-slate-300">Optional Authorization Code Client ID<input name="profile_client_id" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $integrations['parcel2go']['profile_client_configured'] ? 'Saved — leave blank to keep' : 'Only needed for optional consent' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
</div>
</details>
<div class="lg:col-span-2"><button class="rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save Parcel2Go settings</button></div>
</form>
<div class="mt-5 grid gap-3 rounded-xl border border-slate-700 bg-slate-950 p-4 text-sm">
<div class="flex flex-wrap items-center justify-between gap-2"><p class="font-semibold">Optional Parcel2Go account consent</p><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['parcel2go']['profile_connected'], 'bg-amber-500/10 text-amber-200' => ! $integrations['parcel2go']['profile_connected']])>{{ $integrations['parcel2go']['profile_connected'] ? 'Connected' : 'Not connected' }}</span></div>
<p class="text-slate-400">The dashboard tries shipment history and account details with Client Credentials first. Authorization Code consent is optional.</p>
<details class="border-t border-slate-800 pt-3" @if($errors->has('parcel2go')) open @endif>
<summary class="cursor-pointer font-semibold text-brand">Set up optional account consent</summary>
<div class="mt-3 grid gap-3 text-slate-400">
<p>Create an Authorization Code client in Parcel2Go. Set Application URL to <code>{{ url('/') }}</code> and register this exact Redirect URI:</p>
<code class="overflow-x-auto rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-slate-200">{{ $integrations['parcel2go']['callback_url'] }}</code>
<p>Save its Client ID under Advanced API settings, then connect below. If consent is rejected, contact <a class="text-brand underline" href="mailto:apihelp@parcel2go.com">Parcel2Go API support</a>.</p>
@if($integrations['parcel2go']['profile_client_configured'])<form method="post" action="{{ route('parcel2go.connect') }}">@csrf<button class="rounded-xl border border-brand/40 px-4 py-2 font-semibold text-brand hover:bg-brand/10">{{ $integrations['parcel2go']['profile_connected'] ? 'Reconnect account' : 'Connect account' }}</button></form>@endif
</div>
</details>
</div>
</section>
<section id="bambuddy" class="scroll-mt-6 rounded-2xl border border-slate-800 bg-slate-900 p-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div><h3 class="text-lg font-semibold">BamBuddy</h3><p class="mt-1 text-sm text-slate-400">Live print farm data.</p></div>
<span @class(['rounded-full px-3 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['bambuddy']['configured'], 'bg-amber-500/10 text-amber-200' => ! $integrations['bambuddy']['configured']])>{{ $integrations['bambuddy']['configured'] ? ($integrations['bambuddy']['stored'] ? 'Saved in dashboard' : 'Using server settings') : 'Setup needed' }}</span>
</div>
<form method="post" action="{{ route('settings.integrations.update', 'bambuddy') }}" class="mt-5 grid gap-4">
@csrf @method('PUT')
<input type="hidden" name="settings_section" value="bambuddy">
<label class="grid gap-2 text-sm text-slate-300">BamBuddy URL<input name="url" type="url" maxlength="2048" value="{{ old('settings_section') === 'bambuddy' ? old('url', $integrations['bambuddy']['url']) : $integrations['bambuddy']['url'] }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<label class="grid gap-2 text-sm text-slate-300">API key<input name="api_key" type="password" autocomplete="new-password" maxlength="512" placeholder="{{ $integrations['bambuddy']['configured'] ? 'Saved — leave blank to keep' : 'Enter API key' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
<button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save BamBuddy settings</button>
</form>
<p class="mt-3 text-xs text-slate-500">Leave the API key blank to keep its current value.</p>
</section>
<section id="dryer" class="scroll-mt-6 rounded-2xl border border-slate-800 bg-slate-900 p-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div><h3 class="text-lg font-semibold">Filament dryer</h3><p class="mt-1 text-sm text-slate-400">Status, cycles and fault controls.</p></div>
<span @class(['rounded-full px-3 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['dryer']['configured'], 'bg-amber-500/10 text-amber-200' => ! $integrations['dryer']['configured']])>{{ $integrations['dryer']['configured'] ? ($integrations['dryer']['stored'] ? 'Saved in dashboard' : 'Using server settings') : 'Setup needed' }}</span>
</div>
<form method="post" action="{{ route('settings.integrations.update', 'dryer') }}" class="mt-5 grid gap-4">
@csrf @method('PUT')
<input type="hidden" name="settings_section" value="dryer">
<label class="grid gap-2 text-sm text-slate-300">Dryer API URL<input name="url" type="url" maxlength="2048" value="{{ old('settings_section') === 'dryer' ? old('url', $integrations['dryer']['url']) : $integrations['dryer']['url'] }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<label class="grid gap-2 text-sm text-slate-300">API key<input name="api_key" type="password" autocomplete="new-password" maxlength="512" placeholder="{{ $integrations['dryer']['configured'] ? 'Saved — leave blank to keep' : 'Enter API key' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
<button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save dryer settings</button>
</form>
<p class="mt-3 text-xs text-slate-500">Leave the API key blank to keep its current value.</p>
</section>
</div>
<section id="scheduler" class="scroll-mt-6 rounded-2xl border border-slate-800 bg-slate-900 p-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-lg font-semibold">Scheduler</h3>
<p class="mt-1 text-sm text-slate-400">Tune how often recurring Artisan jobs run. Laravel applies a changed frequency on its next scheduler tick, no redeploy needed.</p>
</div>
</div>
<form method="post" action="{{ route('settings.schedules.update') }}" class="mt-6 grid gap-4">
@csrf
@method('PUT')
<div class="grid gap-3">
@foreach($scheduleJobs as $job)
<div class="grid gap-3 rounded-xl border border-slate-700 bg-slate-950 p-4 sm:grid-cols-[1fr_auto_auto] sm:items-center">
<div>
<p class="text-sm font-semibold text-white">{{ $job['name'] }}</p>
<p class="mt-1 text-xs text-slate-400">{{ $job['description'] }}</p>
<p class="mt-1 text-xs font-mono text-slate-500">{{ $job['command'] }}</p>
</div>
<select name="schedules[{{ $job['key'] }}]" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
@foreach($scheduleFrequencyOptions as $value => $label)
<option value="{{ $value }}" @selected(old('schedules.'.$job['key'], $job['frequency']) === $value)>{{ $label }}</option>
@endforeach
</select>
@if($job['manual'] ?? false)
<button type="submit" form="schedule-run-{{ $job['key'] }}" class="w-fit rounded-xl border border-slate-700 px-4 py-3 text-sm font-semibold text-slate-200 hover:border-brand">Run now</button>
@endif
</div>
@endforeach
</div>
<div><button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save schedule settings</button></div>
</form>
@foreach($scheduleJobs as $job)
@if($job['manual'] ?? false)
<form id="schedule-run-{{ $job['key'] }}" method="post" action="{{ route('settings.schedules.run', $job['key']) }}" class="hidden">@csrf</form>
@endif
@endforeach
</section>
@endsection