feat(greenfield): rebuild SlabUploader M1-M3 on clean foundation, all gates green
Clean re-implementation of the full product (not a port of the old M3 tree): - P0 foundation: 13-table schema + per-call sqlite factory (thread-safe), FastAPI app (health, JSON logging + secret redaction, CORS, startup guard). - P1 M1 Capture: mask/sheetKeep, geometry/rect (min-area rect), measure, png crop/inventory/overlay, EXIF decodeOrientedBitmap. - P2 M2 Photos: stateful capture session (batch, pick-cal, apply mask/crop to batch) + photos-ready lineup. - P3 M3 Create: real backend (crypto, woo client, taxonomy sync, pricing, inference Call1/Call2, review, publish, settings, slabs, photos) + real FE review/publish/settings screens consuming the API (no hardcoded Woo ids). - P4 gate exit: check_requirements + check_coverage both exit 0 (137/155 IDs, 0 gaps); backend 130/130, frontend 150/150, build green. Cite: docs/REQUIREMENTS.md (155 rows), docs/MILESTONES.md M1-M3.
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { apiFetch, ApiError } from '../lib/api';
|
||||
import type { SlabDraft, Taxonomy, TaxonomyCategory, TaxonomyTerm } from '../lib/types';
|
||||
import { deriveThicknessBandId } from './thicknessBand';
|
||||
import { keptInOrder, missingMinimums, moveItem, roleLabel, resolvePhotoUrl, type LineupItem, type PhotoRole } from './lineup';
|
||||
import { nextPrimary, selectedSummary } from './woodPrimary';
|
||||
import { buildShortDescription, buildTitle } from './copy';
|
||||
|
||||
export let slab: SlabDraft;
|
||||
export let taxonomy: Taxonomy | null = null;
|
||||
|
||||
// Children of a synced Woo grouper, resolved by slug with a name fallback
|
||||
// (QUALITY-105 / METADATA-5 — never hardcoded ids).
|
||||
function leafChildren(categories: TaxonomyCategory[], slug: string, name: string): TaxonomyCategory[] {
|
||||
if (!categories.length) return [];
|
||||
const grouper =
|
||||
categories.find((c) => (c.slug || '').toLowerCase() === slug) ??
|
||||
categories.find((c) => (c.name || '').trim().toLowerCase() === name);
|
||||
if (!grouper) return [];
|
||||
return categories
|
||||
.filter((c) => c.parent_id === grouper.id && c.id !== grouper.id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
ready: Partial<SlabDraft>;
|
||||
back: void;
|
||||
taxonomy: Taxonomy;
|
||||
saved: SlabDraft;
|
||||
}>();
|
||||
|
||||
// ── Taxonomy lookups (from the synced /api/v1 response — never hardcoded) ──
|
||||
$: categories = taxonomy?.categories ?? [];
|
||||
$: speciesLeaves = leafChildren(categories, 'species', 'Species');
|
||||
$: woodCatLeaves = leafChildren(categories, 'wcat', 'Wood category');
|
||||
$: attributeByName = (name: string) =>
|
||||
(taxonomy?.attributes ?? []).find((a) => a.name.toLowerCase() === name || a.slug.toLowerCase() === name);
|
||||
$: edgeTerms = attributeByName('Edge Type')?.terms ?? [];
|
||||
$: figureTerms = attributeByName('Figure')?.terms ?? [];
|
||||
$: gradeTerms = attributeByName('Grade')?.terms ?? [];
|
||||
$: thicknessTerms = attributeByName('Thickness')?.terms ?? [];
|
||||
$: moistureTerms = attributeByName('Moisture')?.terms ?? [];
|
||||
$: figTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('fig-'));
|
||||
$: featTags = (taxonomy?.tags ?? []).filter((t) => t.name.startsWith('feat-'));
|
||||
$: speciesName = (id: number | null) => categories.find((c) => c.id === id)?.name ?? '';
|
||||
$: figureName = figureTerms.find((t) => t.id === (slab.figureTermIds?.[0] ?? null))?.name ?? null;
|
||||
|
||||
// ── Draft fields ──
|
||||
let sku = slab.sku || '';
|
||||
let speciesId: number | '' = slab.speciesId ?? '';
|
||||
let woodCatIds: number[] = slab.woodCategoryIds.length ? [...slab.woodCategoryIds] : [];
|
||||
let primaryCatId: number | '' = slab.woodCategoryIds[0] ?? '';
|
||||
let edgeId: number | '' = slab.edgeTypeTermId ?? '';
|
||||
let figIds: number[] = slab.figureTermIds.length ? [...slab.figureTermIds] : [];
|
||||
let gradeId: number | '' = slab.gradeTermId ?? '';
|
||||
let moistId: number | '' = slab.moistureTermId ?? '';
|
||||
let figTagIds: number[] = slab.figTagIds ?? [];
|
||||
let featTagIds: number[] = slab.featTagIds ?? [];
|
||||
let price = slab.price || 0;
|
||||
let priceSource: 'recommendation' | 'override' = slab.priceSource;
|
||||
let title = slab.title || '';
|
||||
let shortDesc = slab.shortDescription || '';
|
||||
let description = slab.description || '';
|
||||
|
||||
// Derived thickness band (METADATA-15 / QUALITY-95): no picker, follows thickness_in.
|
||||
$: bandId = deriveThicknessBandId(slab.thicknessIn, thicknessTerms);
|
||||
$: bandName = thicknessTerms.find((t) => t.id === bandId)?.name ?? '';
|
||||
|
||||
$: edgeName = edgeTerms.find((t) => t.id === edgeId)?.name ?? null;
|
||||
$: primaryLeaf = categories.find((c) => c.id === primaryCatId);
|
||||
|
||||
function regenerateCopy() {
|
||||
if (!primaryLeaf || !speciesName(speciesId as number)) return;
|
||||
title = buildTitle(
|
||||
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
|
||||
figureName,
|
||||
speciesName(speciesId as number),
|
||||
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
|
||||
);
|
||||
shortDesc = buildShortDescription(
|
||||
{ lengthIn: slab.lengthIn, widthAvgIn: slab.widthAvgIn, thicknessIn: slab.thicknessIn, bdft: slab.bdft, edgeName },
|
||||
figureName,
|
||||
speciesName(speciesId as number),
|
||||
{ slug: primaryLeaf.slug, name: primaryLeaf.name },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Price: bdft × species rate (REVIEW-25); owner override sticks ──
|
||||
$: priceRec = priceSuggestion();
|
||||
function priceSuggestion(): number | null {
|
||||
const rule = (slab as SlabDraft & { speciesRate?: number | null }).speciesRate;
|
||||
if (!slab.bdft || rule == null) return null;
|
||||
return Math.round(slab.bdft * rule * 100) / 100;
|
||||
}
|
||||
function useRecommendedPrice() {
|
||||
if (priceRec == null) return;
|
||||
price = priceRec;
|
||||
priceSource = 'recommendation';
|
||||
}
|
||||
function onPriceInput() {
|
||||
price = Math.round(price * 100) / 100;
|
||||
priceSource = 'override';
|
||||
}
|
||||
|
||||
// ── Photo strip (REVIEW-5): kept order, leftmost = primary (PUBLISH-45) ──
|
||||
$: lineup = slab.photos.map((photo, index): LineupItem => ({
|
||||
id: photo.id,
|
||||
role: photo.id === slab.calibrationPhotoId ? 'calibration' : photo.processedBlob ? 'overlay' : 'batch',
|
||||
isCalibration: photo.id === slab.calibrationPhotoId,
|
||||
kept: index < slab.photos.length,
|
||||
}));
|
||||
$: lineupMissing = missingMinimums(lineup);
|
||||
$: primaryPhoto = keptInOrder(lineup)[0];
|
||||
|
||||
function toggleKeep(id: string) {
|
||||
const next = lineup.map((it) => (it.id === id ? { ...it, kept: !it.kept } : it));
|
||||
resequence(next);
|
||||
}
|
||||
function movePhoto(from: number, to: number) {
|
||||
resequence(moveItem(lineup, from, to));
|
||||
}
|
||||
function resequence(items: LineupItem[]) {
|
||||
const kept = keptInOrder(items);
|
||||
const update: Partial<SlabDraft> = {
|
||||
photos: kept.map((it, i) => {
|
||||
const photo = slab.photos.find((p) => p.id === it.id);
|
||||
return photo ? { ...photo, seq: i + 1 } : photo;
|
||||
}),
|
||||
};
|
||||
dispatch('saved', { ...slab, ...update, clientRev: slab.clientRev + 1 });
|
||||
}
|
||||
function photoSrc(photoId: string, role: PhotoRole): string {
|
||||
const photo = slab.photos.find((p) => p.id === photoId);
|
||||
if (!photo) return '';
|
||||
if (role === 'batch') return photo.processedBlob ? resolvePhotoUrl(photo.processedBlob, photo.url) : photo.url;
|
||||
if (role === 'overlay') return photo.processedBlob ? resolvePhotoUrl(photo.processedBlob, photo.url) : photo.url;
|
||||
return photo.url;
|
||||
}
|
||||
|
||||
// ── Multi-select helpers ──
|
||||
$: woodCatNames = new Map(woodCatLeaves.map((c) => [c.id, c.name]));
|
||||
$: figNames = new Map(figureTerms.map((t) => [t.id, t.name]));
|
||||
|
||||
function toggleWoodCat(id: number) {
|
||||
const selected = woodCatIds.includes(id) ? woodCatIds.filter((x) => x !== id) : [...woodCatIds, id];
|
||||
woodCatIds = selected;
|
||||
primaryCatId = nextPrimary(selected, primaryCatId);
|
||||
regenerateCopy();
|
||||
}
|
||||
function toggleFig(id: number) {
|
||||
figIds = figIds.includes(id) ? figIds.filter((x) => x !== id) : [...figIds, id];
|
||||
regenerateCopy();
|
||||
}
|
||||
function toggleTag(list: number[], id: number): number[] {
|
||||
return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||
}
|
||||
|
||||
// ── Call 2: long description (METADATA-115) with template fallback ──
|
||||
let genState: 'idle' | 'loading' | 'ok' | 'failed' = 'idle';
|
||||
let genMessage = '';
|
||||
function templateDescription(): string {
|
||||
return (
|
||||
`A ${title || 'slab'} from the mill, measured at ${slab.lengthIn}in long, ` +
|
||||
`${slab.widthAvgIn}in average width, and ${slab.thicknessIn}in thick (${slab.bdft} bdft). ` +
|
||||
'Ready to ship.'
|
||||
);
|
||||
}
|
||||
function useTemplateDescription() {
|
||||
description = templateDescription();
|
||||
genState = 'idle';
|
||||
genMessage = 'Template description applied.';
|
||||
}
|
||||
async function generateDescription() {
|
||||
genState = 'loading';
|
||||
genMessage = '';
|
||||
try {
|
||||
const res = await apiFetch<{ ok: boolean; description: string }>(`/slabs/${slab.id}/inference/call2`, { method: 'POST' });
|
||||
description = res.description;
|
||||
genState = 'ok';
|
||||
genMessage = 'Long description generated (Call 2).';
|
||||
} catch (err) {
|
||||
genState = 'failed';
|
||||
genMessage = err instanceof ApiError ? `${err.message} — use the template fallback.` : 'Generation failed — use the template fallback.';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ready gate (REVIEW-5) ──
|
||||
$: skuOk = /^[A-Za-z0-9-]{3,24}$/.test(sku.trim());
|
||||
$: ready =
|
||||
skuOk && speciesId !== '' && woodCatIds.length > 0 && edgeId !== '' && figIds.length > 0 &&
|
||||
gradeId !== '' && bandId !== null && moistId !== '' && price > 0 && title.trim().length > 0 &&
|
||||
shortDesc.trim().length > 0 && description.trim().length > 0;
|
||||
$: missing = [];
|
||||
$: if (!skuOk) missing.push('SKU (3–24 alnum/-)');
|
||||
$: if (speciesId === '') missing.push('Species');
|
||||
$: if (!woodCatIds.length) missing.push('Wood category');
|
||||
$: if (edgeId === '') missing.push('Edge type');
|
||||
$: if (!figIds.length) missing.push('Figure');
|
||||
$: if (gradeId === '') missing.push('Grade');
|
||||
$: if (bandId === null) missing.push('Thickness band (set thickness)');
|
||||
$: if (moistId === '') missing.push('Moisture');
|
||||
$: if (!(price > 0)) missing.push('Price');
|
||||
$: if (!description.trim()) missing.push('Description');
|
||||
|
||||
async function submitReady() {
|
||||
if (!ready) return;
|
||||
const update: Partial<SlabDraft> = {
|
||||
sku: sku.trim(),
|
||||
speciesId: speciesId as number,
|
||||
woodCategoryIds: woodCatIds,
|
||||
edgeTypeTermId: edgeId as number,
|
||||
figureTermIds: figIds,
|
||||
gradeTermId: gradeId as number,
|
||||
thicknessTermId: bandId,
|
||||
moistureTermId: moistId as number,
|
||||
figTagIds,
|
||||
featTagIds,
|
||||
price,
|
||||
priceSource,
|
||||
title,
|
||||
shortDescription: shortDesc,
|
||||
description,
|
||||
status: 'ready',
|
||||
};
|
||||
dispatch('ready', update);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2>3. Review (METADATA/REVIEW)</h2>
|
||||
{#if !taxonomy}
|
||||
<p>Loading synced taxonomy…</p>
|
||||
{:else if !categories.length}
|
||||
<p class="nudge">Taxonomy is empty — run a Woo sync in Settings first.</p>
|
||||
{:else}
|
||||
<div class="form">
|
||||
<label>SKU <input bind:value={sku} maxlength="24" placeholder="SLAB-UAT-001" /></label>
|
||||
|
||||
<label>Species
|
||||
<select bind:value={speciesId} on:change={regenerateCopy}>
|
||||
<option value="" disabled>Select…</option>
|
||||
{#each speciesLeaves as s (s.id)}<option value={s.id}>{s.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="chip-group">
|
||||
<span>Wood categories <small>— primary drives the title</small></span>
|
||||
{#each woodCatLeaves as c (c.id)}
|
||||
<button class="chip" class:selected={woodCatIds.includes(c.id)} on:click={() => toggleWoodCat(c.id)}>
|
||||
{#if primaryCatId === c.id}<strong>★</strong>{/if}{c.name}
|
||||
</button>
|
||||
{/each}
|
||||
<small class="chip-sum">{selectedSummary(woodCatIds, woodCatNames, 'None selected')}</small>
|
||||
</div>
|
||||
|
||||
<label>Edge type
|
||||
<select bind:value={edgeId} on:change={regenerateCopy}>
|
||||
<option value="" disabled>Select…</option>
|
||||
{#each edgeTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="chip-group">
|
||||
<span>Figure (multi)</span>
|
||||
{#each figureTerms as t (t.id)}
|
||||
<button class="chip" class:selected={figIds.includes(t.id)} on:click={() => toggleFig(t.id)}>{t.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label>Grade
|
||||
<select bind:value={gradeId}>
|
||||
<option value="" disabled>Select…</option>
|
||||
{#each gradeTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="band">
|
||||
<span>Thickness band (derived from {slab.thicknessIn}″)</span>
|
||||
<strong>{bandName || '— set thickness in Measure —'}</strong>
|
||||
</div>
|
||||
|
||||
<label>Moisture
|
||||
<select bind:value={moistId}>
|
||||
<option value="" disabled>Select…</option>
|
||||
{#each moistureTerms as t (t.id)}<option value={t.id}>{t.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if figTags.length}
|
||||
<div class="chip-group">
|
||||
<span>Figure tags</span>
|
||||
{#each figTags as t (t.id)}
|
||||
<button class="chip" class:selected={figTagIds.includes(t.id)} on:click={() => (figTagIds = toggleTag(figTagIds, t.id))}>{t.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if featTags.length}
|
||||
<div class="chip-group">
|
||||
<span>Feature tags</span>
|
||||
{#each featTags as t (t.id)}
|
||||
<button class="chip" class:selected={featTagIds.includes(t.id)} on:click={() => (featTagIds = toggleTag(featTagIds, t.id))}>{t.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="price">
|
||||
<label>Price ($) <input type="number" step="0.01" bind:value={price} on:input={onPriceInput} /></label>
|
||||
{#if priceRec != null}
|
||||
<button on:click={useRecommendedPrice}>Use recommendation ${priceRec} (bdft × species rate)</button>
|
||||
{:else}
|
||||
<small class="nudge">No species rate configured — set it in Settings.</small>
|
||||
{/if}
|
||||
<small>Source: {priceSource}</small>
|
||||
</div>
|
||||
|
||||
<div class="prose">
|
||||
<label>Title (deterministic, editable) <input bind:value={title} /></label>
|
||||
<label>Short description (deterministic, editable) <input bind:value={shortDesc} /></label>
|
||||
<label>Long description
|
||||
<textarea bind:value={description} rows="5"></textarea>
|
||||
</label>
|
||||
<div class="gen">
|
||||
<button on:click={generateDescription} disabled={genState === 'loading'}>
|
||||
{genState === 'loading' ? 'Generating…' : 'Generate (Call 2)'}
|
||||
</button>
|
||||
<button on:click={useTemplateDescription}>Use template</button>
|
||||
{#if genMessage}<p class:ok={genState === 'ok'} class:err={genState === 'failed'}>{genMessage}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="strip">
|
||||
<span>Photo lineup — leftmost kept = primary (cover). Tap to keep/remove; use ‹ › to reorder.</span>
|
||||
{#each lineup as item, i (item.id)}
|
||||
{#if item.kept}
|
||||
<div class="strip-item" class:primary={item.id === primaryPhoto?.id}>
|
||||
<button class="keep" aria-label="keep or remove" on:click={() => toggleKeep(item.id)}>{item.id === primaryPhoto?.id ? '★' : '✓'}</button>
|
||||
<img src={photoSrc(item.id, item.role)} alt={roleLabel(item.role)} />
|
||||
<div class="strip-actions">
|
||||
<button disabled={i === 0} on:click={() => movePhoto(i, i - 1)}>‹</button>
|
||||
<button disabled={i === lineup.length - 1} on:click={() => movePhoto(i, i + 1)}>›</button>
|
||||
</div>
|
||||
<small>{roleLabel(item.role)}</small>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if lineupMissing.length}
|
||||
<p class="nudge">Minimum not met — missing {lineupMissing.map(roleLabel).join(' and ')}.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !ready}
|
||||
<p class="nudge">Still needed: {missing.join(', ')}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button on:click={() => dispatch('back')}>← Back to measure</button>
|
||||
<button on:click={submitReady} disabled={!ready} class="primary">Mark Ready & Publish →</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.form { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:0.9rem; }
|
||||
label { display:block; font-size:0.9rem; }
|
||||
input,select,textarea { width:100%; padding:0.4rem; background:#222; color:#eee; border:1px solid #555; margin-top:0.2rem; }
|
||||
.chip-group { display:flex; flex-wrap:wrap; gap:0.35rem; align-items:center; }
|
||||
.chip { background:#222; color:#eee; border:1px solid #555; padding:0.35rem 0.7rem; border-radius:999px; cursor:pointer; }
|
||||
.chip.selected { background:#0a7; color:#fff; border-color:#0a7; }
|
||||
.chip-sum { width:100%; color:#888; }
|
||||
.band { background:#1a1a1a; padding:0.6rem 0.8rem; border-radius:4px; }
|
||||
.band strong { display:block; color:#0af; }
|
||||
.price button { margin-top:0.3rem; font-size:0.8rem; }
|
||||
.gen { display:flex; gap:0.5rem; align-items:center; margin-top:0.3rem; }
|
||||
.gen .ok { color:#0a7; margin:0; }
|
||||
.gen .err { color:#f88; margin:0; }
|
||||
.strip { grid-column:1/-1; }
|
||||
.strip-item { position:relative; display:inline-block; margin:0.3rem; }
|
||||
.strip-item img { width:140px; height:140px; object-fit:cover; background:#111; border:2px solid #444; }
|
||||
.strip-item.primary img { border-color:#0a7; }
|
||||
.keep { position:absolute; top:4px; left:4px; z-index:1; background:#111; color:#0a7; border:1px solid #0a7; border-radius:4px; }
|
||||
.strip-actions { display:flex; gap:0.25rem; }
|
||||
.nudge { color:#fa0; }
|
||||
.primary { background:#0a7; color:white; border:none; }
|
||||
.actions { display:flex; gap:0.75rem; margin-top:1rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user