feat(slabs): S2 CRUD + optimistic concurrency + soft delete + slab list UI
- backend/slabs.py: create (calibrated, client-derived stored as sent), list (excl soft-deleted, most recent), get, update (single compare-and-set on server_rev, 409 revision_conflict / 409 duplicate_sku / 422 stale_field, status server-derived), delete (soft, published rejected). Explicit column allowlists, parameterized SQL, JSON array fields, commits. - backend/app.py: wire slabs router. - frontend: src/api/slabs.ts client + create/slabs/SlabList.svelte + dev toggle. - tests: 9 slabs tests (create/dup/list/get/update/rev-conflict/dup-sku/soft-delete/ published-delete) with full taxonomy seeding. Verified: pytest 23 passed; check_requirements + check_coverage exit 0; npm build ok.
This commit is contained in:
@@ -10,6 +10,7 @@ import MaskPanel from './capture/MaskPanel.svelte';
|
||||
import AxisPanel from './capture/AxisPanel.svelte';
|
||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
import SlabList from './create/slabs/SlabList.svelte';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
@@ -18,6 +19,7 @@ import Settings from './create/settings/Settings.svelte';
|
||||
let busy = false;
|
||||
let pngUrl: string | null = null;
|
||||
let showSettings = false;
|
||||
let showSlabs = false;
|
||||
|
||||
const unsubCap = session.capture.subscribe((c) => {
|
||||
cap = c;
|
||||
@@ -90,10 +92,16 @@ import Settings from './create/settings/Settings.svelte';
|
||||
{pngUrl}
|
||||
/>
|
||||
{/if}
|
||||
<button on:click={() => (showSettings = !showSettings)} style="margin-top:1rem">Settings (dev)</button>
|
||||
<div style="margin-top:1rem;display:flex;gap:8px">
|
||||
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
|
||||
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
|
||||
</div>
|
||||
{#if showSettings}
|
||||
<Settings />
|
||||
{/if}
|
||||
{#if showSlabs}
|
||||
<SlabList />
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Thin client for the S2 slabs endpoints. Svelte-agnostic.
|
||||
|
||||
export interface Slab {
|
||||
id: string;
|
||||
sku: string;
|
||||
status: string;
|
||||
price: number;
|
||||
length_in: number;
|
||||
thickness_in: number;
|
||||
sqft: number;
|
||||
bdft: number;
|
||||
width_avg_in: number;
|
||||
title: string | null;
|
||||
server_rev: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public code: string | null, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let code: string | null = null;
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error) {
|
||||
code = body.error.code ?? null;
|
||||
message = body.error.message ?? message;
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
throw new ApiError(res.status, code, message);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function listSlabs(): Promise<Slab[]> {
|
||||
return fetch('/api/v1/slabs').then((r) => parse<Slab[]>(r));
|
||||
}
|
||||
|
||||
export function getSlab(id: string): Promise<Slab> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(id)}`).then((r) => parse<Slab>(r));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listSlabs, type Slab } from '../../api/slabs';
|
||||
|
||||
let slabs: Slab[] = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
slabs = await listSlabs();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load slabs';
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="slab-list">
|
||||
<div class="head">
|
||||
<h2>Slabs</h2>
|
||||
<button on:click={() => void load()}>Refresh</button>
|
||||
</div>
|
||||
{#if loading}
|
||||
<p>Loading…</p>
|
||||
{:else if error}
|
||||
<p class="err">{error}</p>
|
||||
{:else if slabs.length === 0}
|
||||
<p class="empty">No slabs yet.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Size (L×T)</th>
|
||||
<th>Avg W</th>
|
||||
<th>sqft</th>
|
||||
<th>Price</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each slabs as s (s.id)}
|
||||
<tr>
|
||||
<td>{s.sku}</td>
|
||||
<td>{s.length_in}″ × {s.thickness_in}″</td>
|
||||
<td>{s.width_avg_in}″</td>
|
||||
<td>{s.sqft}</td>
|
||||
<td>${s.price.toFixed(2)}</td>
|
||||
<td><span class="status" class:ready={s.status === 'ready'}>{s.status}</span></td>
|
||||
<td>{s.updated_at?.replace('T', ' ').slice(0, 16)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.slab-list { max-width: 720px; color: #eee; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #333; font-size: 0.9rem; }
|
||||
.status { text-transform: capitalize; }
|
||||
.status.ready { color: #7fd37f; }
|
||||
.err { color: #e88; }
|
||||
.empty { color: #888; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user