Files
SlabUploader/frontend/src/review/ReviewForm.svelte
T
Ty e5afc7524b fix(fe): upload slab photos to the server before publish
ensureServerSlab sent calibration_photo_id at create, but slab_photos rows are
only created by POST /slabs/{id}/photos, which the frontend never called - so
the INSERT hit FOREIGN KEY (calibration_photo_id REFERENCES slab_photos(id))
and every create failed with a generic 400, blocking the whole create->publish
walk.

- stop sending calibration_photo_id at create
- upload every draft photo (original + processed PNG) right after the slab row
  exists, via new review/photoUpload.ts (multipart FormData, same error
  envelope so failures surface through fail(e))
- link calibration_photo_id in the existing review PUT, once the row exists
- extract the role rule into lineup.photoRole() so the review strip and the
  uploader cannot disagree
2026-09-25 12:14:35 -07:00

423 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, photoRole, 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) => {
const n = name.toLowerCase();
return (taxonomy?.attributes ?? []).find((a) => a.name.toLowerCase() === n || a.slug.toLowerCase() === n);
};
$: edgeTerms = attributeByName('Edge Type')?.terms ?? [];
$: figureTerms = attributeByName('Figure')?.terms ?? [];
$: gradeTerms = attributeByName('Grade')?.terms ?? [];
$: thicknessTerms = attributeByName('Thickness')?.terms ?? [];
$: moistureTerms = attributeByName('Moisture')?.terms ?? [];
// The fig-/feat- family marker is on the tag SLUG (slug 'fig-chat', name
// 'Chatoyant'), so match slug OR name (METADATA-30: bare names are kept).
$: figTags = (taxonomy?.tags ?? []).filter((t) => t.slug.startsWith('fig-') || t.name.startsWith('fig-'));
$: featTags = (taxonomy?.tags ?? []).filter((t) => t.slug.startsWith('feat-') || 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: photoRole(photo.id, slab.calibrationPhotoId, Boolean(photo.processedBlob)),
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();
}
// Figure selections echo transparently between the Figure attribute terms
// (pa_fig) and the synced fig-* product tags, so the Woo product carries the
// same figure in both places without the owner picking it twice. Matched
// case-insensitively on the display name (attribute "Curly" -> tag "Curly").
const normName = (s: string) => s.trim().toLowerCase();
function echoFigTag(termId: number, selected: boolean) {
const term = figureTerms.find((t) => t.id === termId);
if (!term) return;
const key = normName(term.name);
const match = figTags.find((t) => normName(t.name) === key);
if (match) figTagIds = toggleTag(figTagIds, match.id, selected);
}
function echoFigTerm(tagId: number, selected: boolean) {
const tag = figTags.find((t) => t.id === tagId);
if (!tag) return;
const key = normName(tag.name);
const match = figureTerms.find((t) => normName(t.name) === key);
if (match) figIds = selected ? [...new Set([...figIds, match.id])] : figIds.filter((x) => x !== match.id);
}
function toggleFig(id: number) {
const next = figIds.includes(id) ? figIds.filter((x) => x !== id) : [...figIds, id];
figIds = next;
echoFigTag(id, next.includes(id));
regenerateCopy();
}
function toggleFigTag(id: number) {
const next = toggleTag(figTagIds, id);
figTagIds = next;
echoFigTerm(id, next.includes(id));
regenerateCopy();
}
function toggleTag(list: number[], id: number, forceSelected?: boolean): number[] {
const on = forceSelected ?? !list.includes(id);
return on ? [...new Set([...list, id])] : list.filter((x) => x !== 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={() => toggleFigTag(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>