e5afc7524b
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
423 lines
18 KiB
Svelte
423 lines
18 KiB
Svelte
<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>
|