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:
@@ -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