Files
formulation/src/components/PurchasingReview.tsx
T

293 lines
11 KiB
TypeScript
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.
import { useEffect, useMemo, useState } from "preact/hooks";
type Candidate = {
ingredient_id: string;
name: string;
score: number;
};
type Product = {
supplier_id: string;
supplier_sku: string;
name: string;
url?: string;
package?: {
quantity: number;
unit_id: string;
};
prices: Array<{
amount: number;
effective_at: string;
}>;
ingredient_candidates: Candidate[];
};
type Props = {
products: Product[];
ingredients: Array<{ id: string; name: string }>;
};
type Decisions = Record<string, string | null>;
const STORAGE_KEY = "recipe-book-purchasing-decisions-v1";
export default function PurchasingReview({ products, ingredients }: Props) {
const [decisions, setDecisions] = useState<Decisions>({});
const [query, setQuery] = useState("");
const [unresolved, setUnresolved] = useState(true);
useEffect(() => {
try {
setDecisions(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"));
} catch {}
}, []);
const choose = (key: string, value: string | null) => {
const next = { ...decisions, [key]: value };
setDecisions(next);
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
};
const visible = useMemo(() => {
return products.filter((p) => {
const key = `${p.supplier_id}:${p.supplier_sku}`;
const matchesQuery = p.name.toLowerCase().includes(query.toLowerCase()) ||
p.supplier_sku.toLowerCase().includes(query.toLowerCase());
const matchesResolution = !unresolved || !(key in decisions);
return matchesQuery && matchesResolution;
});
}, [products, query, unresolved, decisions]);
const reviewedCount = Object.keys(decisions).length;
const progressPercent = products.length > 0 ? Math.round((reviewedCount / products.length) * 100) : 0;
const download = () => {
const url = URL.createObjectURL(
new Blob(
[
JSON.stringify(
{
schema_version: 1,
generated_at: new Date().toISOString(),
decisions,
},
null,
2
),
],
{ type: "application/json" }
)
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "purchasing-decisions.json";
anchor.click();
URL.revokeObjectURL(url);
};
const supplierLabel = (id: string) => {
if (id === "walmart") return "Walmart";
if (id === "sams_club") return "Sam's Club";
return id.replace("_", " ");
};
return (
<div class="purchasing-review-container">
<div class="purchasing-review-toolbar">
<div class="purchasing-review-stats">
<div class="stats-counter">
<strong class="stats-count">{reviewedCount} / {products.length}</strong>
<span class="stats-label">reviewed ({progressPercent}%)</span>
</div>
<small class="stats-hint">Products without explicit package sizes cannot be imported automatically.</small>
</div>
<div class="purchasing-review-actions">
<div class="purchasing-search-box">
<svg class="search-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="search"
placeholder="Search products or SKU..."
value={query}
onInput={(e) => setQuery(e.currentTarget.value)}
class="search-input"
/>
{query && (
<button class="search-clear-btn" onClick={() => setQuery("")} title="Clear search">
×
</button>
)}
</div>
<label class="purchasing-filter-toggle">
<input
type="checkbox"
checked={unresolved}
onChange={(e) => setUnresolved(e.currentTarget.checked)}
/>
<span class="toggle-track">
<i class="toggle-thumb" />
</span>
<span class="toggle-label">Unresolved only</span>
</label>
<button onClick={download} class="purchasing-export-btn" title="Download JSON decisions file">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>Export decisions</span>
</button>
</div>
</div>
{visible.length > 0 ? (
<div class="purchasing-products-grid">
{visible.map((product) => {
const key = `${product.supplier_id}:${product.supplier_sku}`;
const currentDecision = decisions[key];
const isResolved = key in decisions;
const latestPrice = product.prices.at(-1)?.amount;
return (
<article class={`purchasing-card ${isResolved ? "resolved" : ""}`} key={key}>
<header class="purchasing-card-header">
<div class="purchasing-card-title-group">
<div class="purchasing-badges">
<span class={`supplier-badge ${product.supplier_id}`}>
{supplierLabel(product.supplier_id)}
</span>
<span class="sku-badge">SKU #{product.supplier_sku}</span>
{product.package && (
<span class="package-badge">
{product.package.quantity} {product.package.unit_id.replace("_", " ")}
</span>
)}
{latestPrice != null && (
<span class="price-badge">${latestPrice.toFixed(2)}</span>
)}
</div>
<h3 class="purchasing-product-name">{product.name}</h3>
</div>
{product.url && (
<a
href={product.url}
target="_blank"
rel="noreferrer"
class="purchasing-external-link"
title="Inspect product in new tab"
>
<span>Inspect product</span>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
)}
</header>
<div class="purchasing-candidates-section">
<span class="candidates-heading">Select ingredient mapping:</span>
<div class="candidate-options-list">
{product.ingredient_candidates.map((candidate) => {
const isSelected = currentDecision === candidate.ingredient_id;
const matchPercent = Math.round(candidate.score * 100);
return (
<label
class={`candidate-option-card ${isSelected ? "selected" : ""}`}
key={candidate.ingredient_id}
>
<input
type="radio"
name={key}
checked={isSelected}
onChange={() => choose(key, candidate.ingredient_id)}
class="candidate-radio"
/>
<span class="candidate-custom-radio">
<i />
</span>
<div class="candidate-info">
<strong class="candidate-name">{candidate.name}</strong>
<span class="candidate-meta">{candidate.ingredient_id}</span>
</div>
<span class={`candidate-score-badge ${matchPercent >= 80 ? "high" : ""}`}>
{matchPercent}% match
</span>
</label>
);
})}
<div class="candidate-other-option">
<label class="other-select-label">
<span class="other-select-text">Or choose another ingredient:</span>
<select
value={currentDecision && !product.ingredient_candidates.some((c) => c.ingredient_id === currentDecision) ? currentDecision : ""}
onChange={(e) => choose(key, e.currentTarget.value || null)}
class="purchasing-select"
>
<option value="">Select ingredient</option>
{ingredients.map((i) => (
<option value={i.id} key={i.id}>
{i.name} ({i.id})
</option>
))}
</select>
</label>
</div>
<label class={`candidate-option-card none ${isResolved && currentDecision === null ? "selected" : ""}`}>
<input
type="radio"
name={key}
checked={isResolved && currentDecision === null}
onChange={() => choose(key, null)}
class="candidate-radio"
/>
<span class="candidate-custom-radio">
<i />
</span>
<div class="candidate-info">
<strong class="candidate-name">Not a recipe ingredient</strong>
<span class="candidate-meta">Ignore and do not import this product</span>
</div>
</label>
</div>
</div>
</article>
);
})}
</div>
) : (
<div class="purchasing-empty-state">
<div class="empty-icon-circle">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 14 14"></polyline>
</svg>
</div>
<h3>No products to review</h3>
<p>
{unresolved
? "All candidate products have been reviewed! Uncheck 'Unresolved only' to inspect past decisions."
: "No products matched your search query."}
</p>
{query && (
<button class="purchasing-reset-btn" onClick={() => setQuery("")}>
Clear search filter
</button>
)}
</div>
)}
</div>
);
}