|
|
|
@@ -8,7 +8,8 @@
|
|
|
|
|
import type { CalibratedCapture } from '../flow';
|
|
|
|
|
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
|
|
|
|
|
import MultiSelect from './MultiSelect.svelte';
|
|
|
|
|
import { deriveThicknessTermId } from './thicknessBand';
|
|
|
|
|
import { decodeName } from '../decodeName';
|
|
|
|
|
import { deriveFigTagIds, scopedFeatTags } from './figTags';
|
|
|
|
|
import {
|
|
|
|
|
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
|
|
|
|
|
type LineupItem,
|
|
|
|
@@ -41,7 +42,6 @@
|
|
|
|
|
let figureOpts: Opt[] = [];
|
|
|
|
|
let gradeOpts: Opt[] = [];
|
|
|
|
|
let moistureOpts: Opt[] = [];
|
|
|
|
|
let figTagOpts: Opt[] = [];
|
|
|
|
|
let featTagOpts: Opt[] = [];
|
|
|
|
|
|
|
|
|
|
function deriveOptions() {
|
|
|
|
@@ -63,7 +63,7 @@
|
|
|
|
|
// attributes ignore it. "2-4 in" → 2; a name with no leading number → undefined.
|
|
|
|
|
const m = t.name.match(/\d+(?:[./]\d+)?/);
|
|
|
|
|
const lowIn = m ? Number(m[0]) : undefined;
|
|
|
|
|
return { id: t.id, name: t.name, ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
|
|
|
|
|
return { id: t.id, name: decodeName(t.name), ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
|
|
|
|
@@ -73,9 +73,9 @@
|
|
|
|
|
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
|
|
|
|
const kids = cats.filter((c) => c.parent_id === parent.id);
|
|
|
|
|
const leaves: Opt[] = kids.length
|
|
|
|
|
? kids.map((c) => ({ id: c.id, name: c.name, ai: false }))
|
|
|
|
|
: [{ id: parent.id, name: parent.name, ai: false }];
|
|
|
|
|
return { parent: { id: parent.id, name: parent.name }, leaves };
|
|
|
|
|
? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false }))
|
|
|
|
|
: [{ id: parent.id, name: decodeName(parent.name), ai: false }];
|
|
|
|
|
return { parent: { id: parent.id, name: decodeName(parent.name) }, leaves };
|
|
|
|
|
});
|
|
|
|
|
edgeOpts = termsOf('edge type');
|
|
|
|
|
figureOpts = termsOf('figure');
|
|
|
|
@@ -83,12 +83,12 @@
|
|
|
|
|
// No thickness options: the band is derived server-side and never shown here
|
|
|
|
|
// (METADATA-15 / QUALITY-95).
|
|
|
|
|
moistureOpts = termsOf('moisture');
|
|
|
|
|
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
|
|
|
|
|
// filtered by the family the backend keeps. Never invent a prefix — if the
|
|
|
|
|
// store has none, the picker is legitimately empty.
|
|
|
|
|
// feat-* product tags (METADATA-30): slug prefix with hyphen. fig-* tags are
|
|
|
|
|
// derived from Figure attribute selections (no separate picker).
|
|
|
|
|
const tags: TaxTag[] = tax.tags ?? [];
|
|
|
|
|
figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
|
|
|
|
featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
|
|
|
|
featTagOpts = scopedFeatTags(tags).map((t) => ({
|
|
|
|
|
id: t.id, name: decodeName(t.name), ai: false,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Editable model (mirrors the live slab) ---
|
|
|
|
@@ -148,9 +148,6 @@
|
|
|
|
|
let genBusy = false;
|
|
|
|
|
let genError = '';
|
|
|
|
|
let canGenerateContent = false;
|
|
|
|
|
let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles)
|
|
|
|
|
|
|
|
|
|
const num = (v: unknown) => (typeof v === 'number' ? v : null);
|
|
|
|
|
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
|
|
|
|
|
const fmt2 = (v: number) => v.toFixed(2);
|
|
|
|
|
|
|
|
|
@@ -327,7 +324,6 @@
|
|
|
|
|
figureIds = [...(s.figure_term_ids ?? [])];
|
|
|
|
|
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
|
|
|
|
|
moistureId = s.moisture_term_id ?? '';
|
|
|
|
|
figTagIds = [...(s.fig_tag_ids ?? [])];
|
|
|
|
|
featTagIds = [...(s.feat_tag_ids ?? [])];
|
|
|
|
|
title = s.title ?? '';
|
|
|
|
|
shortDescription = s.short_description ?? '';
|
|
|
|
@@ -338,36 +334,29 @@
|
|
|
|
|
sku = s.sku ?? '';
|
|
|
|
|
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
|
|
|
|
|
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
|
|
|
|
|
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
|
|
|
|
|
// its weight behind the value the owner kept).
|
|
|
|
|
aiRecommendations = [];
|
|
|
|
|
if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id)
|
|
|
|
|
aiRecommendations.push(s.species_id);
|
|
|
|
|
if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7)
|
|
|
|
|
aiRecommendations.push(...(s.figure_term_ids ?? []));
|
|
|
|
|
// fig_tag_ids are derived reactively from figureIds (no hydrate of stored fig tags).
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AI-marked option views (legacy Call 1 badges; Call 1 disabled on this branch).
|
|
|
|
|
$: woodCatGroupsView = woodCatGroups.map((g) => ({
|
|
|
|
|
parent: g.parent,
|
|
|
|
|
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
|
|
|
|
|
}));
|
|
|
|
|
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
|
|
|
|
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
|
|
|
|
// Call 1 out: no AI badges on options. Groups/opts pass through as-is.
|
|
|
|
|
$: woodCatGroupsView = woodCatGroups;
|
|
|
|
|
$: figureOptsView = figureOpts;
|
|
|
|
|
$: featTagOptsView = featTagOpts;
|
|
|
|
|
// fig_tag_ids derived from selected Figure term names ↔ fig-* tag names (casefold).
|
|
|
|
|
$: figTagIds = tax
|
|
|
|
|
? deriveFigTagIds(figureIds, figureOpts, tax.tags ?? [])
|
|
|
|
|
: figTagIds;
|
|
|
|
|
// Flat leaf name lookup for the primary radio set (REVIEW-15).
|
|
|
|
|
$: woodLeafById = new Map(
|
|
|
|
|
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
|
|
|
|
|
);
|
|
|
|
|
$: primaryOpts = woodCatIds
|
|
|
|
|
.map((id) => woodLeafById.get(id))
|
|
|
|
|
.filter((o): o is Opt => !!o)
|
|
|
|
|
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
|
|
|
|
.filter((o): o is Opt => !!o);
|
|
|
|
|
|
|
|
|
|
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
|
|
|
|
|
// measured thickness against the live synced Woo bands. It is deliberately absent
|
|
|
|
|
// from this screen — no owner control, no read-only echo. The only thickness the
|
|
|
|
|
// owner sees here is the measured value in the summary above.
|
|
|
|
|
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
|
|
|
|
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
|
|
|
|
|
// #89: the deterministic title/short_description are written here — the server's
|
|
|
|
|
// review returns the build from the current metadata, and if the field is empty
|
|
|
|
@@ -678,7 +667,7 @@
|
|
|
|
|
|
|
|
|
|
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
|
|
|
|
|
<div class="field" data-testid="wood-cat-field">
|
|
|
|
|
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
|
|
|
|
|
<label class="lbl">Wood categories</label>
|
|
|
|
|
<MultiSelect
|
|
|
|
|
label="Wood categories"
|
|
|
|
|
groups={woodCatGroupsView}
|
|
|
|
@@ -703,7 +692,6 @@
|
|
|
|
|
>
|
|
|
|
|
<span class="ps-radio" aria-hidden="true"></span>
|
|
|
|
|
<span class="ps-name">{o.name}</span>
|
|
|
|
|
{#if o.ai}<span class="ps-tag">AI</span>{/if}
|
|
|
|
|
</button>
|
|
|
|
|
{/each}
|
|
|
|
|
<p class="ps-note">
|
|
|
|
@@ -714,9 +702,9 @@
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Edge type (single, AI-assist) -->
|
|
|
|
|
<!-- Edge type (single) -->
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
|
|
|
|
|
<label class="lbl" for="edge">Edge type</label>
|
|
|
|
|
<select id="edge" class="sel" data-testid="edge-type" bind:this={edgeEl} bind:value={edgeSel} on:change={commitReview}>
|
|
|
|
|
<option value="">— select —</option>
|
|
|
|
|
{#each edgeOpts as o (o.id)}
|
|
|
|
@@ -725,9 +713,9 @@
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Figure (multi, searchable list, AI-assist) -->
|
|
|
|
|
<!-- Figure (multi, searchable list); fig-* tags derived from selections -->
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label class="lbl">Figure <span class="ai">AI assist</span></label>
|
|
|
|
|
<label class="lbl">Figure</label>
|
|
|
|
|
<MultiSelect
|
|
|
|
|
label="Figure"
|
|
|
|
|
options={figureOptsView}
|
|
|
|
@@ -748,28 +736,14 @@
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- fig-* product tags (optional, owner-only, no AI) -->
|
|
|
|
|
<!-- Features = feat-* product tags (optional; Call 1 out — no AI badge) -->
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
|
|
|
|
|
<label class="lbl">Features <span class="sug">optional</span></label>
|
|
|
|
|
<MultiSelect
|
|
|
|
|
label="fig-* tags"
|
|
|
|
|
options={figTagOpts}
|
|
|
|
|
selected={figTagIds}
|
|
|
|
|
placeholder="Choose fig-* tags"
|
|
|
|
|
testid="fig-tag"
|
|
|
|
|
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
|
|
|
|
|
on:commit={commitReview}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- feat-* product tags (optional; Call 1 assist disabled on this branch) -->
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
|
|
|
|
<MultiSelect
|
|
|
|
|
label="feat-* tags"
|
|
|
|
|
label="Features"
|
|
|
|
|
options={featTagOptsView}
|
|
|
|
|
selected={featTagIds}
|
|
|
|
|
placeholder="Choose feat-* tags"
|
|
|
|
|
placeholder="Choose features"
|
|
|
|
|
testid="feat-tag"
|
|
|
|
|
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
|
|
|
|
on:commit={commitReview}
|
|
|
|
@@ -875,7 +849,6 @@
|
|
|
|
|
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
|
|
|
|
|
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
|
|
|
|
|
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
|
|
|
|
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
|
|
|
|
|
/* box-sizing: the app has no global reset, so a width:100% control with padding
|
|
|
|
|
and a border pokes out of its container by that padding+border (20px here) —
|
|
|
|
|
which becomes real page overflow whenever the column sits at the viewport edge
|
|
|
|
@@ -908,7 +881,6 @@
|
|
|
|
|
.ps-opt.on .ps-radio { border-color: #00e5ff; }
|
|
|
|
|
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
|
|
|
|
|
.ps-name { flex: 1; }
|
|
|
|
|
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
|
|
|
|
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
|
|
|
|
|
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
|
|
|
|
.status.ok { background: #122a12; color: #7fd37f; }
|
|
|
|
|