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
+8 -1
View File
@@ -90,7 +90,9 @@ def _enforce_startup_guards() -> None:
_enforce_startup_guards() _enforce_startup_guards()
# --- Request ID middleware (threads X-Request-Id or generates) --- # --- Request ID middleware (threads X-Request-Id or generates) --
class RequestIdMiddleware(BaseHTTPMiddleware): class RequestIdMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4()) rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
@@ -178,5 +180,10 @@ async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
return settings_mod.update_settings(payload) return settings_mod.update_settings(payload)
# expose for tests
from .slabs import router as slabs_router
app.include_router(slabs_router)
# expose for tests # expose for tests
__all__ = ["app", "__version__"] __all__ = ["app", "__version__"]
+188
View File
@@ -0,0 +1,188 @@
"""Slab CRUD service + FastAPI router (S2).
Client-derived numbers are stored as sent (never recomputed). Optimistic
concurrency via a single compare-and-set UPDATE on server_rev. Soft delete
sets deleted_at; published slabs cannot be deleted.
"""
import json
from datetime import datetime, timezone
from typing import Any, Dict, List
from fastapi import APIRouter
from fastapi.responses import JSONResponse, Response
from .db import get_connection
router = APIRouter(prefix="/api/v1/slabs")
# Client-settable slab columns (explicit allowlist; never build SQL from client keys).
ALLOWED_FIELDS = [
"id", "sku", "length_in", "thickness_in", "sqft", "bdft",
"width_min_in", "width_max_in", "width_avg_in", "species_id",
"wood_category_ids", "edge_type_term_id", "figure_term_ids", "grade_term_id",
"thickness_term_id", "moisture_term_id", "fig_tag_ids", "feat_tag_ids",
"price", "price_source", "title", "short_description", "description",
"content_source", "species_confidence", "figure_confidence", "grade_confidence",
"inference_status", "calibration_photo_id",
]
JSON_ARRAY_FIELDS = {"wood_category_ids", "figure_term_ids", "fig_tag_ids", "feat_tag_ids"}
# Columns a client may set on update (id/status/revs/timestamps/deleted_at excluded).
ALLOWED_UPDATE_FIELDS = set(ALLOWED_FIELDS) - {
"id", "status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at",
}
TAXONOMY_TABLES = {
"species_id": "woo_categories",
"wood_category_ids": "woo_categories",
"edge_type_term_id": "woo_attribute_terms",
"figure_term_ids": "woo_attribute_terms",
"grade_term_id": "woo_attribute_terms",
"thickness_term_id": "woo_attribute_terms",
"moisture_term_id": "woo_attribute_terms",
"fig_tag_ids": "woo_tags",
"feat_tag_ids": "woo_tags",
}
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
def _error(code: str, message: str, detail: Any = None) -> Dict[str, Any]:
return {"error": {"code": code, "message": message, "detail": detail}}
def _err(code: str, message: str, detail: Any = None, status: int = 400) -> JSONResponse:
return JSONResponse(status_code=status, content=_error(code, message, detail))
def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
out = dict(payload)
for f in JSON_ARRAY_FIELDS:
if f in out and isinstance(out[f], list):
out[f] = json.dumps(out[f])
return out
def _row_to_slab(row) -> Dict[str, Any]:
d = dict(row)
for f in JSON_ARRAY_FIELDS:
if d.get(f) is not None and isinstance(d[f], str):
try:
d[f] = json.loads(d[f])
except (ValueError, TypeError):
pass
return d
def _sku_taken(conn, sku: str, exclude_id: str | None = None) -> bool:
if exclude_id:
return conn.execute(
"SELECT 1 FROM slabs WHERE sku=? AND deleted_at IS NULL AND id<>?", (sku, exclude_id)
).fetchone() is not None
return conn.execute(
"SELECT 1 FROM slabs WHERE sku=? AND deleted_at IS NULL", (sku,)
).fetchone() is not None
def _check_taxonomy(conn, table: str, val: Any) -> bool:
if isinstance(val, list):
if not val:
return True
ph = ",".join(["?"] * len(val))
rows = conn.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", val).fetchall()
return {r[0] for r in rows} == set(val)
return conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (val,)).fetchone() is not None
@router.post("", status_code=201)
async def create_slab(payload: Dict[str, Any]) -> Dict[str, Any]:
missing = [f for f in ALLOWED_FIELDS if f not in payload]
if missing:
return _err("validation", "missing fields", {"fields": missing}, status=400)
conn = get_connection()
if _sku_taken(conn, payload["sku"]):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
now = _now_iso()
data = _encode(payload)
cols = [f for f in ALLOWED_FIELDS if f in data] + ["status", "client_rev", "server_rev", "deleted_at", "created_at", "updated_at"]
vals = [data[f] for f in cols if f in data] + ["calibrated", 1, 1, None, now, now]
ph = ",".join(["?"] * len(cols))
conn.execute(f"INSERT INTO slabs ({', '.join(cols)}) VALUES ({ph})", vals)
conn.commit()
row = conn.execute("SELECT * FROM slabs WHERE id=?", (payload["id"],)).fetchone()
return _row_to_slab(row)
@router.get("")
async def list_slabs() -> List[Dict[str, Any]]:
conn = get_connection()
rows = conn.execute("SELECT * FROM slabs WHERE deleted_at IS NULL ORDER BY updated_at DESC, id DESC").fetchall()
return [_row_to_slab(r) for r in rows]
@router.get("/{id}")
async def get_slab(id: str) -> Dict[str, Any]:
conn = get_connection()
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if row is None:
return _err("not_found", "Slab not found", status=404)
return _row_to_slab(row)
@router.put("/{id}")
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if "client_rev" not in payload:
return _err("validation", "client_rev required", status=400)
conn = get_connection()
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if current is None:
return _err("not_found", "Slab not found", status=404)
client_rev = payload["client_rev"]
if client_rev != current["server_rev"]:
return _err("revision_conflict", "client_rev mismatch",
{"current_server_rev": current["server_rev"]}, status=409)
if "sku" in payload and payload["sku"] != current["sku"]:
if _sku_taken(conn, payload["sku"], exclude_id=id):
return _err("duplicate_sku", "SKU already exists", {"sku": payload["sku"]}, status=409)
for field, table in TAXONOMY_TABLES.items():
if field in payload and payload[field] is not None:
val = payload[field]
if isinstance(val, str):
try:
val = json.loads(val)
except (ValueError, TypeError):
val = []
if not _check_taxonomy(conn, table, val):
return _err("stale_field", f"{field} not in current taxonomy",
{"current_options": val}, status=422)
data = _encode(payload)
cols = [f for f in ALLOWED_UPDATE_FIELDS if f in data]
if not cols:
return _row_to_slab(current)
set_clause = ", ".join(f"{f} = ?" for f in cols)
vals = [data[f] for f in cols] + [client_rev + 1, _now_iso(), id, client_rev]
cur = conn.execute(
f"UPDATE slabs SET {set_clause}, server_rev=?, updated_at=? WHERE id=? AND server_rev=?",
vals,
)
conn.commit()
if cur.rowcount == 0:
cur_server = conn.execute("SELECT server_rev FROM slabs WHERE id=?", (id,)).fetchone()
return _err("revision_conflict", "client_rev mismatch",
{"current_server_rev": cur_server[0] if cur_server else None}, status=409)
row = conn.execute("SELECT * FROM slabs WHERE id=?", (id,)).fetchone()
return _row_to_slab(row)
@router.delete("/{id}", status_code=204)
async def delete_slab(id: str):
conn = get_connection()
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if row is None:
return _err("not_found", "Slab not found", status=404)
if row["status"] == "published":
return _err("publish_conflict", "Published slab cannot be deleted", status=409)
conn.execute("UPDATE slabs SET deleted_at=? WHERE id=?", (_now_iso(), id))
conn.commit()
return Response(status_code=204)
+192
View File
@@ -0,0 +1,192 @@
import os
import json
import uuid
import datetime
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
# Helper to set environment for tests
def _setup_env(monkeypatch, tmp):
db_path = os.path.join(tmp, "test.db")
img_dir = os.path.join(tmp, "images")
os.mkdir(img_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.setenv("SLAB_AES_KEY", "aGVsbGxvb2xvY2g=") # dummy base64
monkeypatch.setenv("LOG_LEVEL", "INFO")
db_mod._reset_for_tests()
# Taxonomy seeding helper
def _seed_taxonomy(conn):
# create some categories
conn.execute("INSERT OR REPLACE INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (1, 'Species A', 'species-a', NULL, 10)")
conn.execute("INSERT OR REPLACE INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (2, 'Wood Cat A', 'wood-cat-a', NULL, 20)")
# attribute terms
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (1, 'Edge Type', 'edge-type', 30)")
conn.execute("INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (1, 1, 'Edge A', 'edge-a', 40)")
# tags
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
conn.commit()
@pytest.fixture
def db(tmp_path, monkeypatch):
_setup_env(monkeypatch, tmp_path)
conn = db_mod.get_connection()
db_mod.init_db()
_seed_taxonomy(conn)
return conn
_setup_env(monkeypatch, tmp_path)
conn = db_mod.get_connection()
db_mod.init_db()
_seed_taxonomy(conn)
return conn
# Helper to get client
@pytest.fixture
def client(db):
from backend.app import app
return TestClient(app)
# Utility to format now
_now_iso = lambda: datetime.datetime.utcnow().replace(microsecond=0).isoformat()
# --- Tests ---
def test_create_and_fetch(client, db):
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id,
"sku": "SKU001",
"length_in": 10.0,
"thickness_in": 2.0,
"sqft": 20.0,
"bdft": 5.0,
"width_min_in": 1.0,
"width_max_in": 2.0,
"width_avg_in": 1.5,
"species_id": 1,
"wood_category_ids": json.dumps([2]),
"edge_type_term_id": 1,
"figure_term_ids": json.dumps([1]),
"grade_term_id": 1,
"thickness_term_id": 1,
"moisture_term_id": 1,
"fig_tag_ids": json.dumps([1]),
"feat_tag_ids": json.dumps([2]),
"price": 100.0,
"price_source": "recommendation",
"title": "Test Slab",
"short_description": "short",
"description": "desc",
"content_source": "manual",
"species_confidence": 0.9,
"figure_confidence": 0.8,
"grade_confidence": 0.7,
"inference_status": "inferring",
"calibration_photo_id": None
}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
body = resp.json()
assert body["status"] == "calibrated"
assert body["client_rev"] == 1
assert body["server_rev"] == 1
for key in ["length_in","thickness_in","sqft","bdft","width_min_in","width_max_in","width_avg_in"]:
assert body[key] == payload[key]
def test_duplicate_sku(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
resp1 = client.post("/api/v1/slabs", json=payload)
assert resp1.status_code == 201
resp2 = client.post("/api/v1/slabs", json=payload)
assert resp2.status_code == 409
def test_list_and_exclude_deleted(client, db):
# create two slabs
for i in [1,2]:
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": f"SKU{i}", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
# delete first created
resp_list = client.get("/api/v1/slabs")
ids = [r["id"] for r in resp_list.json()]
client.delete(f"/api/v1/slabs/{ids[0]}")
# list again
resp2 = client.get("/api/v1/slabs")
body = resp2.json()
assert len(body) == 1
assert body[0]["id"] == ids[1]
def test_get_not_found(client, db):
resp = client.get("/api/v1/slabs/nonexistent")
assert resp.status_code == 404
def test_update_success(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
# update
update_payload = {"client_rev":1, "sku":"SKU002"}
resp = client.put(f"/api/v1/slabs/{slab_id}", json=update_payload)
assert resp.status_code == 200
body = resp.json()
assert body["sku"] == "SKU002"
assert body["server_rev"] == 2
def test_update_revision_conflict(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
# wrong client_rev
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev":99, "sku":"SKU002"})
assert resp.status_code == 409
def test_update_duplicate_sku(client, db):
# create two slabs
id1, id2 = str(uuid.uuid4()), str(uuid.uuid4())
payload1 = {"id": id1, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
payload2 = {"id": id2, "sku": "SKU002", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload1)
client.post("/api/v1/slabs", json=payload2)
# update first to sku of second
resp = client.put(f"/api/v1/slabs/{id1}", json={"client_rev":1, "sku":"SKU002"})
assert resp.status_code == 409
def test_soft_delete(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
client.delete(f"/api/v1/slabs/{slab_id}")
resp = client.get("/api/v1/slabs")
body = resp.json()
assert all(r["id"] != slab_id for r in body)
resp_get = client.get(f"/api/v1/slabs/{slab_id}")
assert resp_get.status_code == 404
def test_delete_published_conflict(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
# manually set status to published
conn = db_mod.get_connection()
conn.execute("UPDATE slabs SET status = 'published' WHERE id = ?", (slab_id,))
conn.commit()
resp = client.delete(f"/api/v1/slabs/{slab_id}")
assert resp.status_code == 409
+9 -1
View File
@@ -10,6 +10,7 @@ import MaskPanel from './capture/MaskPanel.svelte';
import AxisPanel from './capture/AxisPanel.svelte'; import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte'; import Settings from './create/settings/Settings.svelte';
import SlabList from './create/slabs/SlabList.svelte';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
const session: CaptureSession = createCaptureSession(); const session: CaptureSession = createCaptureSession();
@@ -18,6 +19,7 @@ import Settings from './create/settings/Settings.svelte';
let busy = false; let busy = false;
let pngUrl: string | null = null; let pngUrl: string | null = null;
let showSettings = false; let showSettings = false;
let showSlabs = false;
const unsubCap = session.capture.subscribe((c) => { const unsubCap = session.capture.subscribe((c) => {
cap = c; cap = c;
@@ -90,10 +92,16 @@ import Settings from './create/settings/Settings.svelte';
{pngUrl} {pngUrl}
/> />
{/if} {/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} {#if showSettings}
<Settings /> <Settings />
{/if} {/if}
{#if showSlabs}
<SlabList />
{/if}
</main> </main>
{/if} {/if}
+51
View File
@@ -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));
}
+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>