293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|