Add HL3D branding, Etsy Live/Draft paging and Projects lifecycle
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Deploy HickLab3D Dashboard / Deploy to production (push) Failing after 23s
Replace the old touchmark/PNG identity with the supplied lockup and monogram, segmented role-aware navigation, Live/Draft listing filters with generation-based cache invalidation, and admin Projects covering immutable file revisions, listing live pointers, and exact-decimal filament/packaging/shipping costing. Additive schema only; no Etsy publication. Isolated clone tests: 301 tests, 1162 assertions.
This commit is contained in:
+36
-6
@@ -3,15 +3,45 @@
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
/*
|
||||
* HL3D brand tokens (Brand Guidelines, HL3D_Brand_Identity.pdf): carbon #111316,
|
||||
* alloy #A7ADB5, fluorescent yellow #E7FF00, ice #F4F6F8, slate #515963.
|
||||
* No proprietary font files are bundled; "Avenir Next" falls back to the local
|
||||
* system sans stack when the licensed font isn't installed, and Menlo is used
|
||||
* for revision/reference labels with a monospace fallback.
|
||||
*/
|
||||
@theme {
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--color-brand: #e3f504;
|
||||
--color-slate-950: #0b0c0d;
|
||||
--color-slate-900: #17191b;
|
||||
--font-sans: 'Avenir Next', 'Avenir', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: Menlo, ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
--color-brand: #E7FF00;
|
||||
--color-carbon: #111316;
|
||||
--color-alloy: #A7ADB5;
|
||||
--color-ice: #F4F6F8;
|
||||
--color-hl-slate: #515963;
|
||||
--color-slate-950: #111316;
|
||||
--color-slate-900: #1a1d21;
|
||||
--color-slate-800: #292c30;
|
||||
--color-slate-700: #3c4146;
|
||||
--color-slate-500: #9a9fa3;
|
||||
--color-slate-400: #b8bec4;
|
||||
--color-slate-400: #A7ADB5;
|
||||
--color-slate-300: #d6dade;
|
||||
--color-slate-100: #f4f5f5;
|
||||
--color-slate-100: #F4F6F8;
|
||||
}
|
||||
|
||||
.nav-group summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group[open] > summary .nav-group-chevron {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.nav-group:not([open]) > summary .nav-group-chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-group-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -1,8 +1,8 @@
|
||||
const chartBase = {
|
||||
chart: {
|
||||
background: 'transparent',
|
||||
foreColor: '#b8bec4',
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||
foreColor: '#A7ADB5',
|
||||
fontFamily: "'Avenir Next', 'Avenir', ui-sans-serif, system-ui, sans-serif",
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
},
|
||||
@@ -31,7 +31,7 @@ if (document.querySelector('[data-dashboard-chart]')) {
|
||||
options = {
|
||||
...chartBase,
|
||||
chart: { ...chartBase.chart, type: 'area', height },
|
||||
colors: ['#e3f504'],
|
||||
colors: ['#E7FF00'],
|
||||
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() : '' } },
|
||||
@@ -47,11 +47,11 @@ if (document.querySelector('[data-dashboard-chart]')) {
|
||||
chart: { ...chartBase.chart, type: 'donut', height },
|
||||
labels: data.map((item) => item.label),
|
||||
series: data.map((item) => item.count),
|
||||
colors: ['#e3f504', '#7dd3a3', '#f0a46b', '#f17fa5', '#8b9df0'],
|
||||
legend: { position: 'bottom', fontSize: '12px', labels: { colors: '#b8bec4' } },
|
||||
colors: ['#E7FF00', '#7dd3a3', '#f0a46b', '#f17fa5', '#8b9df0'],
|
||||
legend: { position: 'bottom', fontSize: '12px', labels: { colors: '#A7ADB5' } },
|
||||
plotOptions: { pie: { donut: { size: '68%', labels: { show: true, total: { show: true, label: 'Printers' } } } } },
|
||||
dataLabels: { enabled: false },
|
||||
stroke: { colors: ['#17191b'], width: 2 },
|
||||
stroke: { colors: ['#1a1d21'], width: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,3 +63,4 @@ if (document.querySelector('[data-dashboard-chart]')) {
|
||||
}
|
||||
|
||||
import './notifications';
|
||||
import './navigation';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const costLines = document.querySelector('[data-cost-lines]');
|
||||
if (costLines) {
|
||||
const template = costLines.firstElementChild.cloneNode(true);
|
||||
let nextIndex = costLines.children.length;
|
||||
document.querySelector('[data-add-cost-line]')?.addEventListener('click', () => {
|
||||
if (costLines.children.length >= 50) { return; }
|
||||
const line = template.cloneNode(true);
|
||||
line.querySelector('legend').textContent = `Cost line ${nextIndex + 1}`;
|
||||
line.querySelectorAll('[name]').forEach((input) => {
|
||||
input.name = input.name.replace(/lines\[\d+\]/, `lines[${nextIndex}]`);
|
||||
if (input.tagName === 'INPUT') { input.value = input.name.includes('units_per_parcel') ? '1' : input.name.includes('waste_quantity') ? '0' : ''; }
|
||||
});
|
||||
nextIndex += 1;
|
||||
costLines.append(line);
|
||||
line.querySelector('select').focus();
|
||||
});
|
||||
costLines.addEventListener('click', (event) => {
|
||||
if (event.target.matches('[data-remove-cost-line]') && costLines.children.length > 1) { event.target.closest('[data-cost-line]').remove(); }
|
||||
});
|
||||
}
|
||||
|
||||
const sidebar = document.querySelector('[data-sidebar]');
|
||||
const sidebarToggle = document.querySelector('[data-sidebar-toggle]');
|
||||
|
||||
if (sidebar && sidebarToggle) {
|
||||
const collapsedKey = 'hl3d-sidebar-collapsed';
|
||||
|
||||
const applyCollapsed = (collapsed) => {
|
||||
sidebar.classList.toggle('lg:w-20', collapsed);
|
||||
sidebar.querySelectorAll('[data-sidebar-label]').forEach((label) => {
|
||||
label.classList.toggle('hidden', collapsed);
|
||||
});
|
||||
sidebar.querySelectorAll('[data-sidebar-expanded-mark]').forEach((mark) => {
|
||||
mark.classList.toggle('hidden', collapsed);
|
||||
});
|
||||
sidebar.querySelectorAll('[data-sidebar-collapsed-mark]').forEach((mark) => {
|
||||
mark.classList.toggle('hidden', !collapsed);
|
||||
});
|
||||
sidebarToggle.setAttribute('aria-pressed', collapsed ? 'true' : 'false');
|
||||
sidebarToggle.textContent = collapsed ? 'Expand' : 'Collapse';
|
||||
};
|
||||
|
||||
applyCollapsed(localStorage.getItem(collapsedKey) === '1');
|
||||
|
||||
sidebarToggle.addEventListener('click', () => {
|
||||
const collapsed = !sidebar.classList.contains('lg:w-20');
|
||||
localStorage.setItem(collapsedKey, collapsed ? '1' : '0');
|
||||
applyCollapsed(collapsed);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-nav-group]').forEach((details) => {
|
||||
const storageKey = `hl3d-nav-group-${details.dataset.navGroup}`;
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
|
||||
if (stored !== null) {
|
||||
details.open = stored === '1';
|
||||
}
|
||||
|
||||
details.addEventListener('toggle', () => {
|
||||
localStorage.setItem(storageKey, details.open ? '1' : '0');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#0b0c0d"><link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}"><title>Sign in · HickLab3D</title>@vite(['resources/css/app.css', 'resources/js/app.js'])</head>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#111316"><link rel="icon" type="image/svg+xml" href="{{ asset('branding/hl3d-monogram-dark-background.svg') }}"><title>Sign in · HL3D</title>@vite(['resources/css/app.css', 'resources/js/app.js'])</head>
|
||||
<body class="grid min-h-screen place-items-center bg-slate-950 px-5 font-sans text-slate-100">
|
||||
<main class="w-full max-w-lg rounded-3xl border border-slate-800 bg-slate-900 p-6 shadow-2xl shadow-black/30 sm:p-8">
|
||||
<div class="mb-8 rounded-2xl bg-[#d8dad8] p-4 ring-1 ring-white/15 sm:p-6"><img src="{{ asset('hicklab3d-full.png') }}" alt="HickLab3D" class="mx-auto h-auto w-full max-w-[390px]"></div>
|
||||
<div class="mb-8 rounded-2xl bg-ice p-4 ring-1 ring-white/15 sm:p-6"><img src="{{ asset('branding/hl3d-lockup-light-background.svg') }}" alt="HickLab3D · Engineered Excellence" class="mx-auto h-auto w-full max-w-[390px]"></div>
|
||||
<h2 class="text-xl font-semibold">Welcome back</h2><p class="mt-1 text-sm text-slate-400">Sign in to your dashboard.</p>
|
||||
<form method="post" action="{{ route('login.store') }}" class="mt-7 flex flex-col gap-5">@csrf
|
||||
<label class="flex flex-col gap-2 text-sm font-medium">Username<input name="username" value="{{ old('username') }}" required autocomplete="username" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 outline-none focus:border-brand"></label>
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Etsy shop')
|
||||
@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">Sales</p><h2 class="mt-2 text-2xl font-bold">{{ $connection?->shop_name ?? 'Connect your Etsy shop' }}</h2><p class="mt-1 text-slate-400">Paid orders awaiting dispatch and active listings.</p></div>@if($configured)<form method="post" action="{{ route('etsy.connect') }}">@csrf<button class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">{{ $connection ? 'Reconnect Etsy' : 'Connect Etsy' }}</button></form>@else<a href="{{ route('settings.index') }}" class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Set up Etsy</a>@endif</div>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Sales</p><h2 class="mt-2 text-2xl font-bold">{{ $connection?->shop_name ?? 'Connect your Etsy shop' }}</h2><p class="mt-1 text-slate-400">Paid orders awaiting dispatch and listings by state.</p></div>@if($configured)<form method="post" action="{{ route('etsy.connect') }}">@csrf<button class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">{{ $connection ? 'Reconnect Etsy' : 'Connect Etsy' }}</button></form>@else<a href="{{ route('settings.index') }}" class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Set up Etsy</a>@endif</div>
|
||||
@if(! $configured)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">Add your Etsy app key and shared secret in <a class="underline" href="{{ route('settings.index') }}">Settings</a>, then connect your shop.</div>@endif
|
||||
@if($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endif
|
||||
@if($stateError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $stateError }}</div>@endif
|
||||
@if($pageError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $pageError }}</div>@endif
|
||||
@if($receiptsError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $receiptsError }}</div>@endif
|
||||
@if($listingsError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $listingsError }}</div>@endif
|
||||
@if($connection)
|
||||
<div class="grid gap-4 sm:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Awaiting dispatch</p><p class="mt-3 text-4xl font-bold">{{ count($receipts) }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Active listings shown</p><p class="mt-3 text-4xl font-bold">{{ count($listings) }}</p></div></div>
|
||||
<div class="grid gap-4 sm:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Awaiting dispatch</p><p class="mt-3 text-4xl font-bold">{{ count($receipts) }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">{{ ucfirst($state === 'active' ? 'Live' : 'Draft') }} listings total</p><p class="mt-3 text-4xl font-bold">{{ $listingCount }}</p></div></div>
|
||||
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900"><h3 class="p-6 text-lg font-semibold">Orders to dispatch</h3><table class="w-full min-w-[650px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Receipt</th><th class="p-4">Buyer</th><th class="p-4">Placed</th><th class="p-4">Total</th></tr></thead><tbody class="divide-y divide-slate-800">@forelse($receipts as $receipt)<tr><td class="p-4 font-medium">#{{ $receipt['receipt_id'] ?? '—' }}</td><td class="p-4">{{ $receipt['name'] ?? $receipt['buyer_email'] ?? 'Buyer' }}</td><td class="p-4">{{ isset($receipt['create_timestamp']) ? \Illuminate\Support\Carbon::createFromTimestamp($receipt['create_timestamp'])->format('d M Y') : '—' }}</td><td class="p-4">{{ isset($receipt['grandtotal']['amount']) ? ($receipt['grandtotal']['currency_code'] ?? 'GBP').' '.number_format((float) $receipt['grandtotal']['amount'] / max(1, (int) ($receipt['grandtotal']['divisor'] ?? 100)), 2) : '—' }}</td></tr>@empty<tr><td colspan="4" class="p-6 text-slate-400">No paid orders awaiting dispatch.</td></tr>@endforelse</tbody></table></section>
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">Active listings</h3><div class="mt-4 divide-y divide-slate-800">@forelse($listings as $listing)<div class="flex items-center justify-between gap-4 py-3"><span>{{ $listing['title'] ?? 'Untitled listing' }}</span><span class="shrink-0 text-sm text-slate-400">{{ $listing['quantity'] ?? 0 }} available</span></div>@empty<p class="py-3 text-sm text-slate-400">No listings returned.</p>@endforelse</div></section>
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h3 class="text-lg font-semibold">Listings</h3>
|
||||
<div class="flex gap-2 text-sm" role="tablist" aria-label="Listing state">
|
||||
<a href="{{ route('etsy.index', ['state' => 'active']) }}" role="tab" aria-selected="{{ $state === 'active' ? 'true' : 'false' }}" @class(['rounded-lg px-3 py-2', 'bg-brand/10 text-brand' => $state === 'active', 'text-slate-400 hover:text-white' => $state !== 'active'])>Live</a>
|
||||
<a href="{{ route('etsy.index', ['state' => 'draft']) }}" role="tab" aria-selected="{{ $state === 'draft' ? 'true' : 'false' }}" @class(['rounded-lg px-3 py-2', 'bg-brand/10 text-brand' => $state === 'draft', 'text-slate-400 hover:text-white' => $state !== 'draft'])>Drafts</a>
|
||||
</div>
|
||||
</div>
|
||||
@php
|
||||
$snapshotAge = is_array($listingSnapshot ?? null) && ! empty($listingSnapshot['fetched_at'])
|
||||
? \Illuminate\Support\Carbon::createFromTimestamp($listingSnapshot['fetched_at'])->diffForHumans()
|
||||
: null;
|
||||
$snapshotStale = is_array($listingSnapshot ?? null) && ! empty($listingSnapshot['stale']);
|
||||
@endphp
|
||||
<p class="mt-3 text-xs text-slate-500">Showing {{ count($listings) }} of {{ $listingCount }} {{ $state === 'active' ? 'live' : 'draft' }} listings.{{ $snapshotAge ? ' Snapshot '.$snapshotAge.($snapshotStale ? ' · cached after a failed refresh, not asserted live' : '').'.' : '' }}</p>
|
||||
<div class="mt-4 divide-y divide-slate-800">@forelse($listings as $listing)<div class="flex items-center justify-between gap-4 py-3"><span>{{ $listing['title'] ?? 'Untitled listing' }}</span><span class="shrink-0 text-sm text-slate-400">{{ $listing['quantity'] ?? 0 }} available</span></div>@empty<p class="py-3 text-sm text-slate-400">No {{ $state === 'active' ? 'live' : 'draft' }} listings returned.</p>@endforelse</div>
|
||||
@if($listingCount > $perPage || $page > 1)
|
||||
<nav class="mt-4 flex items-center justify-between text-sm text-slate-400" aria-label="Listings pagination">
|
||||
@if($page > 1)<a href="{{ route('etsy.index', ['page' => $page - 1, 'state' => $state]) }}" class="hover:text-white">← Previous</a>@else<span></span>@endif
|
||||
<span>Page {{ $page }}</span>
|
||||
@if($listingCount > $page * $perPage)<a href="{{ route('etsy.index', ['page' => $page + 1, 'state' => $state]) }}" class="hover:text-white">Next →</a>@else<span></span>@endif
|
||||
</nav>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@@ -3,39 +3,48 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#0b0c0d">
|
||||
<meta name="theme-color" content="#111316">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}">
|
||||
<title>@yield('title', 'Dashboard') · HickLab3D</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset('branding/hl3d-monogram-dark-background.svg') }}">
|
||||
<title>@yield('title', 'Dashboard') · HL3D</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 font-sans text-slate-100 antialiased">
|
||||
<div class="min-h-screen lg:flex">
|
||||
<aside class="border-b border-slate-800 bg-slate-900/80 lg:fixed lg:inset-y-0 lg:w-64 lg:border-r lg:border-b-0">
|
||||
<div class="flex h-20 items-center gap-3 px-6">
|
||||
<a href="{{ route('dashboard') }}" class="grid size-10 shrink-0 place-items-center rounded-xl bg-brand p-1.5 transition-transform hover:scale-105" aria-label="HickLab3D overview"><img src="{{ asset('hicklab3d-touchmark.svg') }}" alt="" class="size-full object-contain"></a>
|
||||
<div><strong class="block text-lg tracking-tight">HickLab3D</strong><span class="text-xs text-slate-400">Operations studio</span></div>
|
||||
<aside data-sidebar class="hidden border-b border-slate-800 bg-slate-900/80 lg:fixed lg:inset-y-0 lg:block lg:w-64 lg:border-r lg:border-b-0">
|
||||
<div class="flex h-20 items-center px-6">
|
||||
<a href="{{ route('dashboard') }}" class="flex shrink-0 items-center gap-3 transition-transform hover:scale-105 motion-reduce:transition-none" aria-label="HL3D overview">
|
||||
<img src="{{ asset('branding/hl3d-lockup-dark-background.svg') }}" alt="HickLab3D" class="h-9 w-auto" data-sidebar-expanded-mark>
|
||||
<img src="{{ asset('branding/hl3d-monogram-dark-background.svg') }}" alt="HickLab3D" class="hidden size-8 object-contain" data-sidebar-collapsed-mark>
|
||||
</a>
|
||||
</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'], ['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>
|
||||
@endif
|
||||
<nav class="flex flex-col gap-1 px-4 pb-4" aria-label="Main navigation">
|
||||
@foreach($navigationGroups as $group)
|
||||
@continue($group['items'] === [])
|
||||
<details class="nav-group mb-1" data-nav-group="{{ $group['key'] }}" open>
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between rounded-lg px-2 py-2 text-xs font-semibold uppercase tracking-[.15em] text-slate-500 hover:text-slate-300">
|
||||
<span data-sidebar-label>{{ $group['label'] }}</span>
|
||||
<svg class="nav-group-chevron size-3 shrink-0 transition-transform motion-reduce:transition-none" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M3 4.5 6 7.5 9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</summary>
|
||||
<div class="flex flex-col gap-1 pt-1">
|
||||
@foreach($group['items'] as $item)
|
||||
<a href="{{ route($item['route']) }}" @if(request()->routeIs($item['activePatterns'])) aria-current="page" @endif @class(['block shrink-0 truncate rounded-xl px-4 py-3 text-sm font-medium transition-colors hover:bg-slate-800 hover:text-white motion-reduce:transition-none', 'bg-brand/10 text-brand' => request()->routeIs($item['activePatterns']), 'text-slate-400' => ! request()->routeIs($item['activePatterns'])])>{{ $item['label'] }}</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endforeach
|
||||
@if(auth()->user()->role === 'admin')
|
||||
<a href="{{ route('settings.index') }}" @class(['shrink-0 rounded-xl px-4 py-3 text-sm font-medium hover:bg-slate-800', 'bg-brand/10 text-brand' => request()->routeIs('settings.*'), 'text-slate-400' => !request()->routeIs('settings.*')])>Settings</a>
|
||||
<a href="{{ route('users.index') }}" @class(['shrink-0 rounded-xl px-4 py-3 text-sm font-medium hover:bg-slate-800', 'bg-brand/10 text-brand' => request()->routeIs('users.*'), 'text-slate-400' => !request()->routeIs('users.*')])>Users</a>
|
||||
@endif
|
||||
</nav>
|
||||
<div class="px-6 pb-5 text-xs text-slate-500 lg:absolute lg:bottom-0">Made for the HickLab3D workshop</div>
|
||||
<div class="px-6 pb-5 text-xs text-slate-500 lg:absolute lg:bottom-0" data-sidebar-label>Made for the HL3D workshop</div>
|
||||
<button type="button" data-sidebar-toggle class="mx-4 mb-4 hidden w-auto rounded-lg border border-slate-700 px-3 py-2 text-xs text-slate-400 hover:border-brand hover:text-white lg:block" aria-pressed="false">Collapse</button>
|
||||
</aside>
|
||||
<div class="min-w-0 flex-1 lg:pl-64">
|
||||
<div class="min-w-0 flex-1 lg:pl-64" data-content>
|
||||
<header class="flex min-h-20 items-center justify-between gap-4 border-b border-slate-800 px-6 lg:px-10">
|
||||
<div><p class="text-xs font-semibold uppercase tracking-[.2em] text-brand">Workshop dashboard</p><h1 class="text-xl font-bold">@yield('title', 'Overview')</h1></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" popovertarget="mobile-nav" popovertargetaction="show" class="rounded-lg border border-slate-700 p-2 text-slate-300 hover:border-brand hover:text-white lg:hidden" aria-label="Open navigation menu">
|
||||
<svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
<div><p class="text-xs font-semibold uppercase tracking-[.2em] text-brand">Operate</p><h1 class="text-xl font-bold">@yield('title', 'Overview')</h1></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">@include('partials.notifications')<span class="hidden text-sm text-slate-400 sm:inline">{{ auth()->user()->username }}</span><form method="post" action="{{ route('logout') }}">@csrf<button class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-300 hover:border-brand hover:text-white">Sign out</button></form></div>
|
||||
</header>
|
||||
<main class="flex w-full flex-col gap-7 px-6 py-8 lg:px-10">
|
||||
@@ -45,6 +54,29 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-nav" popover="auto" class="fixed inset-0 m-0 h-screen w-full max-w-none border-none bg-slate-950 p-0 lg:hidden" aria-label="Navigation menu">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between border-b border-slate-800 px-6 py-5">
|
||||
<strong class="text-lg">HL3D navigation</strong>
|
||||
<button type="button" popovertarget="mobile-nav" popovertargetaction="hide" class="rounded-lg border border-slate-700 p-2 text-slate-300" aria-label="Close navigation menu">
|
||||
<svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-6" aria-label="Main navigation">
|
||||
@foreach($navigationGroups as $group)
|
||||
@continue($group['items'] === [])
|
||||
<div>
|
||||
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-[.15em] text-slate-500">{{ $group['label'] }}</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
@foreach($group['items'] as $item)
|
||||
<a href="{{ route($item['route']) }}" @if(request()->routeIs($item['activePatterns'])) aria-current="page" @endif @class(['rounded-xl px-4 py-3 text-sm font-medium', 'bg-brand/10 text-brand' => request()->routeIs($item['activePatterns']), 'text-slate-300' => ! request()->routeIs($item['activePatterns'])])>{{ $item['label'] }}</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'New project')
|
||||
@section('content')
|
||||
<div><h2 class="text-2xl font-bold">New project</h2><p class="mt-1 text-slate-400">Create the product aggregate. Revisions, files and listings are added afterwards.</p></div>
|
||||
<form method="post" action="{{ route('projects.store') }}" class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900 p-6 md:grid-cols-2">
|
||||
@csrf
|
||||
<label class="grid gap-1 text-sm text-slate-400">Reference<input name="reference" value="{{ old('reference') }}" required maxlength="64" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">Title<input name="title" value="{{ old('title') }}" required maxlength="255" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400 md:col-span-2">Notes<textarea name="notes" rows="3" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">{{ old('notes') }}</textarea></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">Currency<input name="currency" value="{{ old('currency', 'GBP') }}" maxlength="3" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 uppercase"></label>
|
||||
<div class="md:col-span-2"><button class="rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Create project</button></div>
|
||||
</form>
|
||||
@endsection
|
||||
@@ -0,0 +1,16 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Edit project')
|
||||
@section('content')
|
||||
<div><h2 class="text-2xl font-bold">Edit {{ $project->reference }}</h2></div>
|
||||
<form method="post" action="{{ route('projects.update', $project) }}" class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900 p-6 md:grid-cols-2">
|
||||
@csrf @method('PATCH')
|
||||
<label class="grid gap-1 text-sm text-slate-400">Title<input name="title" value="{{ old('title', $project->title) }}" required maxlength="255" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">Stage
|
||||
<select name="stage" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
|
||||
@foreach(\App\Models\Project::STAGES as $stage)<option value="{{ $stage }}" @selected($project->stage === $stage)>{{ ucfirst($stage) }}</option>@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm text-slate-400 md:col-span-2">Notes<textarea name="notes" rows="3" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">{{ old('notes', $project->notes) }}</textarea></label>
|
||||
<div class="md:col-span-2"><button class="rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Save changes</button></div>
|
||||
</form>
|
||||
@endsection
|
||||
@@ -0,0 +1,35 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Projects')
|
||||
@section('content')
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div><h2 class="text-2xl font-bold">Projects</h2><p class="mt-1 text-slate-400">Product aggregates: revisions, files, listing mappings and costing.</p></div>
|
||||
<a href="{{ route('projects.create') }}" class="rounded-xl bg-brand px-4 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">New project</a>
|
||||
</div>
|
||||
<form method="get" action="{{ route('projects.index') }}" class="flex flex-wrap items-center gap-3 rounded-2xl border border-slate-800 bg-slate-900 p-4">
|
||||
<input type="search" name="search" value="{{ request('search') }}" placeholder="Search title or reference" class="flex-1 rounded-xl border border-slate-700 bg-slate-950 px-4 py-2 text-sm">
|
||||
<select name="stage" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-2 text-sm">
|
||||
<option value="">All stages</option>
|
||||
@foreach($stages as $stage)<option value="{{ $stage }}" @selected(request('stage') === $stage)>{{ ucfirst($stage) }}</option>@endforeach
|
||||
</select>
|
||||
<button class="rounded-xl border border-slate-700 px-4 py-2 text-sm text-slate-300 hover:border-brand hover:text-white">Filter</button>
|
||||
</form>
|
||||
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900">
|
||||
<table class="w-full min-w-[720px] text-left text-sm">
|
||||
<thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Reference</th><th class="p-4">Title</th><th class="p-4">Stage</th><th class="p-4">Revisions</th><th class="p-4">Listings</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
@forelse($projects as $project)
|
||||
<tr class="hover:bg-slate-800/40">
|
||||
<td class="p-4 font-medium"><a class="text-brand hover:underline" href="{{ route('projects.show', $project) }}">{{ $project->reference }}</a></td>
|
||||
<td class="p-4">{{ $project->title }}</td>
|
||||
<td class="p-4"><span class="rounded-full bg-slate-800 px-2 py-1 text-xs capitalize">{{ $project->stage }}</span>@if($project->isArchived())<span class="ml-2 rounded-full bg-rose-500/10 px-2 py-1 text-xs text-rose-300">Archived</span>@endif</td>
|
||||
<td class="p-4">{{ $project->revisions_count }}</td>
|
||||
<td class="p-4">{{ $project->listing_links_count }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="p-6 text-slate-400">No projects yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{ $projects->links() }}
|
||||
@endsection
|
||||
@@ -0,0 +1,177 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', $project->reference)
|
||||
@section('content')
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[.2em] text-brand">{{ $project->reference }}</p>
|
||||
<h2 class="mt-2 text-2xl font-bold">{{ $project->title }}</h2>
|
||||
<p class="mt-1 text-slate-400">Stage: <span class="capitalize">{{ $project->stage }}</span> · Currency {{ $project->currency }} @if($project->isArchived())· <span class="text-rose-300">Archived</span>@endif</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('projects.edit', $project) }}" class="rounded-xl border border-slate-700 px-4 py-3 text-sm text-slate-300 hover:border-brand hover:text-white">Edit</a>
|
||||
@unless($project->isArchived())
|
||||
<form method="post" action="{{ route('projects.archive', $project) }}" onsubmit="return confirm('Archive this project? History is preserved.')">@csrf<button class="rounded-xl border border-rose-500/40 px-4 py-3 text-sm text-rose-300">Archive</button></form>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
@if($project->notes)<p class="rounded-2xl border border-slate-800 bg-slate-900 p-4 text-sm text-slate-300">{{ $project->notes }}</p>@endif
|
||||
|
||||
<section>
|
||||
<h3 class="text-lg font-semibold">Revisions & files</h3>
|
||||
<div class="mt-3 grid gap-3">
|
||||
@forelse($project->revisions as $revision)
|
||||
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="font-semibold">Revision {{ $revision->revision_number }} <span class="ml-2 rounded-full bg-slate-800 px-2 py-1 text-xs capitalize">{{ $revision->status }}</span></p>
|
||||
@if(! $revision->isApproved())
|
||||
<form method="post" action="{{ route('projects.revisions.approve', [$project, $revision]) }}"><input type="hidden">@csrf<button class="rounded-lg border border-brand/40 px-3 py-2 text-xs text-brand">Approve</button></form>
|
||||
@else
|
||||
<span class="text-xs text-slate-400">Approved by {{ $revision->approver?->username }} on {{ $revision->approved_at?->format('d M Y') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($revision->change_notes)<p class="mt-2 text-sm text-slate-400">{{ $revision->change_notes }}</p>@endif
|
||||
<ul class="mt-3 divide-y divide-slate-800 text-sm">
|
||||
@foreach($revision->files as $file)
|
||||
<li class="flex items-center justify-between gap-3 py-2">
|
||||
<span><span class="rounded-full bg-slate-800 px-2 py-0.5 text-xs uppercase">{{ $file->extension }}</span> {{ $file->original_filename }} <span class="font-mono text-xs text-slate-500">({{ number_format($file->byte_size / 1024, 1) }} KB)</span></span>
|
||||
<a class="text-brand hover:underline" href="{{ route('projects.files.download', [$project, $file]) }}">Download</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</article>
|
||||
@empty
|
||||
<p class="text-sm text-slate-400">No revisions uploaded yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<details class="mt-4 rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-brand">Upload a new revision</summary>
|
||||
<form method="post" action="{{ route('projects.revisions.store', $project) }}" enctype="multipart/form-data" class="mt-4 grid gap-3">
|
||||
@csrf
|
||||
<label class="grid gap-1 text-sm text-slate-400">Change notes<textarea name="change_notes" rows="2" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></textarea></label>
|
||||
<label class="grid gap-1 text-sm text-slate-400">Files (STL, 3MF or G-code; up to 10, 100 MiB each)<input type="file" name="files[]" multiple class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-1 text-sm">Fusion project (optional)<input name="fusion_project_id" maxlength="255" class="rounded-lg bg-slate-950 p-3"></label>
|
||||
<label class="grid gap-1 text-sm">Fusion document (optional)<input name="fusion_document_id" maxlength="255" class="rounded-lg bg-slate-950 p-3"></label>
|
||||
<label class="grid gap-1 text-sm">Fusion native version (optional)<input name="fusion_version" maxlength="255" class="rounded-lg bg-slate-950 p-3"></label>
|
||||
<div><button class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-slate-950">Upload revision</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-lg font-semibold">Listings</h3>
|
||||
<div class="mt-3 grid gap-3">
|
||||
@forelse($project->listingLinks as $link)
|
||||
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="font-semibold">Listing #{{ $link->listing_id }} <span class="ml-2 rounded-full bg-slate-800 px-2 py-1 text-xs">{{ $link->variation_key }}</span>@if($link->isDetached())<span class="ml-2 rounded-full bg-rose-500/10 px-2 py-1 text-xs text-rose-300">Detached</span>@endif</p>
|
||||
@unless($link->isDetached())
|
||||
<form method="post" action="{{ route('projects.listings.destroy', [$project, $link]) }}">@csrf @method('DELETE')<button class="rounded-lg border border-rose-500/40 px-3 py-2 text-xs text-rose-300">Detach</button></form>
|
||||
@endunless
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-400">{{ $link->title_snapshot }} · State: {{ $link->state_snapshot ?? 'unknown' }} · Synced {{ $link->synced_at?->diffForHumans() ?? 'never' }}</p>
|
||||
@if($link->liveRelease)
|
||||
<p class="mt-2 text-sm text-brand">Live: revision {{ $link->liveRelease->revision->revision_number }} ({{ $link->liveRelease->action }} on {{ $link->liveRelease->released_at->format('d M Y') }})</p>
|
||||
@else
|
||||
<p class="mt-2 text-sm text-slate-500">No release pointer set yet.</p>
|
||||
@endif
|
||||
<details class="mt-3"><summary class="cursor-pointer text-xs text-brand">Release / revert</summary>
|
||||
<form method="post" action="{{ route('projects.listings.release', [$project, $link]) }}" class="mt-3 grid gap-2 md:grid-cols-4">
|
||||
@csrf
|
||||
<select name="revision_id" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100"><option value="">Revision…</option>@foreach($project->revisions->where('status', 'approved') as $rev)<option value="{{ $rev->id }}">Rev {{ $rev->revision_number }}</option>@endforeach</select>
|
||||
<select name="cost_snapshot_id" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100"><option value="">Cost snapshot…</option>@foreach($project->costSnapshots as $snap)<option value="{{ $snap->id }}">#{{ $snap->id }} {{ $snap->basis }} {{ $snap->grand_total ?? 'n/a' }}</option>@endforeach</select>
|
||||
<input type="hidden" name="lock_version" value="{{ $link->lock_version }}">
|
||||
<button class="rounded-lg bg-brand px-3 py-2 text-sm font-semibold text-slate-950">Release</button>
|
||||
</form>
|
||||
@if($project->releases->isNotEmpty())
|
||||
<form method="post" action="{{ route('projects.listings.revert', [$project, $link]) }}" class="mt-3 flex flex-wrap gap-3">
|
||||
@csrf
|
||||
<label class="grid gap-1 text-sm">Restore historical release<select name="release_id" required class="rounded-lg bg-slate-950 p-2">@foreach($project->releases as $release)<option value="{{ $release->id }}">#{{ $release->id }} · revision {{ $release->revision->revision_number }}</option>@endforeach</select></label>
|
||||
<input type="hidden" name="lock_version" value="{{ $link->lock_version }}">
|
||||
<button class="rounded-lg border border-brand px-3 py-2 text-sm text-brand">Revert with new history entry</button>
|
||||
</form>
|
||||
@endif
|
||||
</details>
|
||||
</article>
|
||||
@empty
|
||||
<p class="text-sm text-slate-400">No listings linked yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<details class="mt-4 rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-brand">Link an Etsy listing</summary>
|
||||
<form method="post" action="{{ route('projects.listings.store', $project) }}" class="mt-4 flex flex-wrap gap-3">
|
||||
@csrf
|
||||
<input type="number" name="listing_id" placeholder="Etsy listing ID" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
|
||||
<button class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-slate-950">Verify & link</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-lg font-semibold">Costing</h3>
|
||||
<div class="mt-3 overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900">
|
||||
<table class="w-full min-w-[720px] text-left text-sm">
|
||||
<thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Revision</th><th class="p-4">Basis</th><th class="p-4">Filament</th><th class="p-4">Packaging</th><th class="p-4">Shipping</th><th class="p-4">Total</th><th class="p-4">Per unit</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
@forelse($project->costSnapshots as $snapshot)
|
||||
<tr>
|
||||
<td class="p-4">Rev {{ $snapshot->revision->revision_number }}</td>
|
||||
<td class="p-4 capitalize">{{ $snapshot->basis }}</td>
|
||||
<td class="p-4">{{ $snapshot->filament_total ?? 'not costed' }}</td>
|
||||
<td class="p-4">{{ $snapshot->packaging_total ?? 'not costed' }}</td>
|
||||
<td class="p-4">{{ $snapshot->shipping_total ?? 'not costed' }}</td>
|
||||
<td class="p-4 font-semibold">{{ $snapshot->grand_total ?? 'not costed' }}</td>
|
||||
<td class="p-4">{{ $snapshot->perUnitGrandTotal() ?? '—' }} / {{ $snapshot->batch_units }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="7" class="p-6 text-slate-400">No cost snapshots recorded.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="mt-4 rounded-2xl border border-slate-800 bg-slate-900 p-5">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-brand">Record a cost snapshot</summary>
|
||||
<form method="post" action="{{ route('projects.cost-snapshots.store', $project) }}" class="mt-4 grid gap-3">
|
||||
@csrf
|
||||
<p class="text-sm text-slate-400">{{ $project->currency }} only. Filament and packaging quantities cover the batch. Shipping quantities describe parcels in one shipment; cost is allocated by units per parcel to the saleable yield. Enter zero intentionally; omit uncosted categories. Excludes fees, tax, labour and energy — not net profit.</p>
|
||||
<div class="grid gap-3 md:grid-cols-4">
|
||||
<label class="grid gap-1 text-sm">Revision<select name="revision_id" required class="rounded-lg bg-slate-950 p-3">@foreach($project->revisions as $rev)<option value="{{ $rev->id }}">Rev {{ $rev->revision_number }}</option>@endforeach</select></label>
|
||||
<label class="grid gap-1 text-sm">Basis<select name="basis" class="rounded-lg bg-slate-950 p-3"><option value="estimate">Estimate</option><option value="actual">Actual</option></select></label>
|
||||
<label class="grid gap-1 text-sm">Currency<input name="currency" value="{{ $project->currency }}" readonly class="rounded-lg bg-slate-950 p-3"></label>
|
||||
<label class="grid gap-1 text-sm">Saleable batch yield<input type="number" name="batch_units" value="1" min="1" max="100000" required class="rounded-lg bg-slate-950 p-3"></label>
|
||||
</div>
|
||||
<div data-cost-lines class="grid gap-3">
|
||||
@for($i = 0; $i < 3; $i++)
|
||||
<fieldset class="grid gap-3 rounded-xl border border-slate-700 p-3 sm:grid-cols-2 lg:grid-cols-3" data-cost-line>
|
||||
<legend class="px-1 text-sm">Cost line {{ $i + 1 }}</legend>
|
||||
<label class="grid gap-1 text-sm">Category<select name="lines[{{ $i }}][category]" class="rounded-lg bg-slate-950 p-2"><option value="filament" @selected($i === 0)>Filament</option><option value="packaging" @selected($i === 1)>Packaging</option><option value="shipping" @selected($i === 2)>Shipping</option></select></label>
|
||||
<label class="grid gap-1 text-sm">Material / component / service<input name="lines[{{ $i }}][description]" required class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Quantity<input name="lines[{{ $i }}][quantity]" inputmode="decimal" required class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Unit<select name="lines[{{ $i }}][quantity_unit]" class="rounded-lg bg-slate-950 p-2"><option value="g" @selected($i === 0)>g (filament)</option><option value="unit" @selected($i === 1)>unit (packaging)</option><option value="parcel" @selected($i === 2)>parcel (shipping)</option></select></label>
|
||||
<label class="grid gap-1 text-sm">Rate per kg / component / parcel<input name="lines[{{ $i }}][unit_rate]" inputmode="decimal" required class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Additional waste / support (not already included)<input name="lines[{{ $i }}][waste_quantity]" value="0" inputmode="decimal" class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Units per parcel (shipping)<input name="lines[{{ $i }}][units_per_parcel]" type="number" min="1" value="1" class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Source / confirmed invoice reference<input name="lines[{{ $i }}][source_reference]" maxlength="255" class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<label class="grid gap-1 text-sm">Source price date<input name="lines[{{ $i }}][source_price_date]" type="date" class="rounded-lg bg-slate-950 p-2"></label>
|
||||
<button type="button" data-remove-cost-line class="text-left text-sm text-rose-300">Remove line</button>
|
||||
</fieldset>
|
||||
@endfor
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3"><button type="button" data-add-cost-line class="rounded-lg border border-slate-600 px-4 py-2 text-sm">Add cost line</button><button class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-slate-950">Record snapshot</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-lg font-semibold">History</h3>
|
||||
<ul class="mt-3 divide-y divide-slate-800 rounded-2xl border border-slate-800 bg-slate-900 text-sm">
|
||||
@forelse($project->events as $event)
|
||||
<li class="flex items-center justify-between gap-3 p-4">
|
||||
<span>{{ str($event->event_type)->replace('_', ' ')->ucfirst() }} @if($event->actor)by {{ $event->actor->username }}@endif</span>
|
||||
<span class="text-xs text-slate-500">{{ $event->created_at->format('d M Y H:i') }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="p-6 text-slate-400">No events recorded yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user