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:
2026-09-19 16:29:06 -07:00
parent a0032ddd2d
commit cc5241f7da
6 changed files with 523 additions and 2 deletions
+75
View File
@@ -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>