feat(review): S5 pricing rules + deterministic title/short desc + server-derived ready + Review UI
- backend/pricing.py: /admin/pricing CRUD (one flat $/bdft per species leaf, SETTINGS-35).
- backend/review.py: POST /slabs/{id}/review -> deterministic title + short description
(7 leaf patterns, length ft/in split, thickness 1/8-in precision, METADATA-80/85/90),
price suggestion (bdft x species rate, REVIEW-25), server-derived ready + missing set
(REVIEW-5).
- frontend: create/review/Review.svelte (slab id, primary category, price override,
ready state + generated copy) + dev toggle.
- tests: 3 pricing + 4 review tests (title no-dims, ready set, price suggestion,
knife-blank dims).
Verified: pytest 43 passed; gates exit 0; npm build ok.
This commit is contained in:
@@ -12,6 +12,7 @@ import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
import Settings from './create/settings/Settings.svelte';
|
||||
import SlabList from './create/slabs/SlabList.svelte';
|
||||
import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
||||
import Review from './create/review/Review.svelte';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
@@ -22,6 +23,7 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
||||
let showSettings = false;
|
||||
let showSlabs = false;
|
||||
let showPhotos = false;
|
||||
let showReview = false;
|
||||
|
||||
const unsubCap = session.capture.subscribe((c) => {
|
||||
cap = c;
|
||||
@@ -98,6 +100,7 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
||||
<button on:click={() => (showSettings = !showSettings)}>Settings (dev)</button>
|
||||
<button on:click={() => (showSlabs = !showSlabs)}>Slabs (dev)</button>
|
||||
<button on:click={() => (showPhotos = !showPhotos)}>Photos (dev)</button>
|
||||
<button on:click={() => (showReview = !showReview)}>Review (dev)</button>
|
||||
</div>
|
||||
{#if showSettings}
|
||||
<Settings />
|
||||
@@ -108,6 +111,9 @@ import PhotoAttach from './create/photos/PhotoAttach.svelte';
|
||||
{#if showPhotos}
|
||||
<PhotoAttach />
|
||||
{/if}
|
||||
{#if showReview}
|
||||
<Review />
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
let slabId = '';
|
||||
let primaryCategoryId: number | '' = '';
|
||||
let price = '';
|
||||
let result: {
|
||||
ready: boolean;
|
||||
missing: string[];
|
||||
title: string | null;
|
||||
short_description: string | null;
|
||||
price: number | null;
|
||||
price_suggestion: number | null;
|
||||
primary_wood_category_id: number | null;
|
||||
} | null = null;
|
||||
let error = '';
|
||||
let busy = false;
|
||||
|
||||
async function review() {
|
||||
if (!slabId) {
|
||||
error = 'Enter a slab id.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
error = '';
|
||||
result = null;
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (primaryCategoryId !== '') payload.primary_wood_category_id = primaryCategoryId;
|
||||
if (price !== '') payload.price = Number(price);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (res.ok) result = body;
|
||||
else error = body.error?.message || `review failed (${res.status})`;
|
||||
} catch {
|
||||
error = 'network error';
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="review">
|
||||
<h2>Review to Ready</h2>
|
||||
<div>
|
||||
<label>Slab ID</label>
|
||||
<input bind:value={slabId} placeholder="uuid" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Primary wood category id (optional; defaults to first selected)</label>
|
||||
<input type="number" bind:value={primaryCategoryId} placeholder="auto" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Price override (optional; suggestion = bdft × species rate)</label>
|
||||
<input type="number" step="0.01" bind:value={price} placeholder="use suggestion" />
|
||||
</div>
|
||||
<button on:click={review} disabled={busy}>{busy ? 'Reviewing…' : 'Review'}</button>
|
||||
{#if error}<p class="err">{error}</p>{/if}
|
||||
|
||||
{#if result}
|
||||
<p class="ready" class:ok={result.ready}>
|
||||
{result.ready ? 'READY for publish' : `Not ready — missing: ${result.missing.join(', ')}`}
|
||||
</p>
|
||||
<div><strong>Title:</strong> {result.title ?? '(none)'}</div>
|
||||
<div><strong>Short description:</strong> {result.short_description ?? '(none)'}</div>
|
||||
<div>
|
||||
<strong>Price:</strong> {result.price != null ? `$${result.price.toFixed(2)}` : '(empty — enter required)'}
|
||||
{#if result.price_suggestion != null && result.price_suggestion !== result.price}
|
||||
<span class="sug"> (suggested ${result.price_suggestion.toFixed(2)})</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.review { max-width: 560px; color: #eee; }
|
||||
label { display: block; margin: 8px 0 4px; }
|
||||
input { width: 100%; padding: 6px; }
|
||||
button { margin-top: 12px; }
|
||||
.ready { margin-top: 12px; font-weight: 600; }
|
||||
.ready.ok { color: #7fd37f; }
|
||||
.ready:not(.ok) { color: #e8a; }
|
||||
.err { color: #e88; }
|
||||
.sug { color: #aaa; font-weight: 400; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user