feat(ui): align recipe books, archive, and purchasing review to Meez design system
This commit is contained in:
@@ -1,9 +1,169 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import {openDatabase,refreshSiteProjection} from "../../../lib/database";
|
||||
const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/",303);
|
||||
if(Astro.request.method==="POST"){const form=await Astro.request.formData(),type=String(form.get("type")),id=String(form.get("id"));const tables:{[key:string]:string}={recipe:"recipes",ingredient:"ingredients",book:"collections"};if(tables[type])database.prepare(`UPDATE ${tables[type]} SET deleted_at=NULL${type==="ingredient"?",status='active'":""} WHERE id=?`).run(id);refreshSiteProjection(database);database.close();return Astro.redirect("/app/archive/",303);}
|
||||
const items=[...(database.prepare("SELECT id,title name,deleted_at FROM recipes WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"recipe"})),...(database.prepare("SELECT id,name,deleted_at FROM ingredients WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"ingredient"})),...(database.prepare("SELECT id,name,deleted_at FROM collections WHERE deleted_at IS NOT NULL").all() as any[]).map(x=>({...x,type:"book"}))].sort((a,b)=>a.name.localeCompare(b.name));database.close();
|
||||
import DetailUtility from "../../../components/DetailUtility.astro";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../lib/database";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/", 303);
|
||||
|
||||
if (Astro.request.method === "POST") {
|
||||
const form = await Astro.request.formData();
|
||||
const type = String(form.get("type"));
|
||||
const id = String(form.get("id"));
|
||||
const tables: Record<string, string> = { recipe: "recipes", ingredient: "ingredients", book: "collections" };
|
||||
if (tables[type]) {
|
||||
database.prepare(`UPDATE ${tables[type]} SET deleted_at=NULL${type === "ingredient" ? ",status='active'" : ""} WHERE id=?`).run(id);
|
||||
refreshSiteProjection(database);
|
||||
}
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
const recipes = (database.prepare("SELECT id, title AS name, deleted_at FROM recipes WHERE deleted_at IS NOT NULL").all() as any[]).map((x) => ({ ...x, type: "recipe" as const }));
|
||||
const ingredients = (database.prepare("SELECT id, name, deleted_at FROM ingredients WHERE deleted_at IS NOT NULL").all() as any[]).map((x) => ({ ...x, type: "ingredient" as const }));
|
||||
const books = (database.prepare("SELECT id, name, deleted_at FROM collections WHERE deleted_at IS NOT NULL").all() as any[]).map((x) => ({ ...x, type: "book" as const }));
|
||||
|
||||
const allItems = [...recipes, ...ingredients, ...books].sort((a, b) => a.name.localeCompare(b.name));
|
||||
database.close();
|
||||
|
||||
const requestedFilter = Astro.url.searchParams.get("type") ?? "all";
|
||||
const query = (Astro.url.searchParams.get("q") ?? "").trim().toLowerCase();
|
||||
|
||||
const filteredItems = allItems.filter((item) => {
|
||||
if (requestedFilter !== "all" && item.type !== requestedFilter) return false;
|
||||
if (query && !item.name.toLowerCase().includes(query)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function formatDeleteDate(dateStr: string | null) {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(dateStr.includes("Z") || dateStr.includes("T") ? dateStr : `${dateStr.replace(" ", "T")}Z`);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
---
|
||||
<BaseLayout title="Archive"><section class="shell archive-workspace"><header><div><a href="/app/">← All items</a><h1>Archive</h1><p>Restore recipes, ingredients, and recipe books removed from the active workspace.</p></div></header>{items.length?items.map(item=><form method="post" class="archive-row"><input type="hidden" name="type" value={item.type}/><input type="hidden" name="id" value={item.id}/><span class={`workspace-pill-icon ${item.type}`}>{item.type==="recipe"?"▦":item.type==="book"?"▣":"●"}</span><span><strong>{item.name}</strong><small>{item.type} · deleted {item.deleted_at}</small></span><button>Restore</button></form>):<p class="empty-state">Nothing has been archived.</p>}</section></BaseLayout>
|
||||
|
||||
<BaseLayout title="Archive" immersive>
|
||||
<div class="archive-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="archive-workspace">
|
||||
<header class="archive-header">
|
||||
<div class="archive-header-left">
|
||||
<nav class="archive-breadcrumbs">
|
||||
<a href="/app/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>All items</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>Archive</h1>
|
||||
<p class="archive-subtitle">
|
||||
Restore recipes, ingredients, and recipe books removed from the active workspace.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="archive-toolbar">
|
||||
<nav class="archive-filter-chips">
|
||||
<a href="/app/archive/" class:list={["archive-chip", { active: requestedFilter === "all" }]}>
|
||||
<span>All</span>
|
||||
<span class="chip-count">{allItems.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=recipe" class:list={["archive-chip", { active: requestedFilter === "recipe" }]}>
|
||||
<span>Recipes</span>
|
||||
<span class="chip-count">{recipes.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=ingredient" class:list={["archive-chip", { active: requestedFilter === "ingredient" }]}>
|
||||
<span>Ingredients</span>
|
||||
<span class="chip-count">{ingredients.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=book" class:list={["archive-chip", { active: requestedFilter === "book" }]}>
|
||||
<span>Recipe books</span>
|
||||
<span class="chip-count">{books.length}</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{filteredItems.length > 0 ? (
|
||||
<div class="archive-table">
|
||||
<div class="archive-table-head">
|
||||
<span class="head-col type">Type</span>
|
||||
<span class="head-col name">Item Name</span>
|
||||
<span class="head-col date">Deleted Date</span>
|
||||
<span class="head-col action">Action</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-table-body">
|
||||
{filteredItems.map((item) => (
|
||||
<div class="archive-table-row">
|
||||
<div class="archive-type-cell">
|
||||
<span class:list={["workspace-pill-icon", item.type]}>
|
||||
{item.type === "recipe" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" transform="scale(1.1, 1.1) translate(4px, 3.5px)"/>
|
||||
</svg>
|
||||
)}
|
||||
{item.type === "ingredient" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z" transform="scale(1.3, 1.3) translate(2.5px, 1px)"/>
|
||||
</svg>
|
||||
)}
|
||||
{item.type === "book" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z" transform="scale(1.1, 1.1) translate(3.5px, 2.5px)"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-name-cell">
|
||||
<strong class="archive-item-title">{titleCase(item.name)}</strong>
|
||||
<span class="archive-item-type">{item.type}</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-date-cell">
|
||||
<span class="archive-date-badge">Deleted {formatDeleteDate(item.deleted_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="archive-action-cell">
|
||||
<form method="post">
|
||||
<input type="hidden" name="type" value={item.type} />
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
<button type="submit" class="archive-restore-btn">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
|
||||
<path d="M3 3v5h5"></path>
|
||||
</svg>
|
||||
<span>Restore</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="archive-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">
|
||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
||||
<rect x="1" y="3" width="22" height="5"></rect>
|
||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Nothing in the archive</h3>
|
||||
<p>Archived recipes, ingredients, and recipe books will appear here and can be restored anytime.</p>
|
||||
<a href="/app/" class="archive-back-btn">Return to workspace</a>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,19 +1,235 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import {readOnlyMode} from "../../../../lib/runtime";
|
||||
import {openDatabase,refreshSiteProjection} from "../../../../lib/database";
|
||||
const id=Astro.params.id!,editing=!readOnlyMode&&Astro.url.searchParams.get("edit")==="1";
|
||||
const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/?error=database-missing",303);
|
||||
let error="";
|
||||
if(Astro.request.method==="POST")try{const form=await Astro.request.formData(),intent=String(form.get("intent"));
|
||||
if(intent==="details"){const name=String(form.get("name")??"").trim();if(!name)throw new Error("Name is required.");database.prepare("UPDATE collections SET name=?,description=? WHERE id=?").run(name,String(form.get("description")??"").trim()||null,id);}
|
||||
if(intent==="membership"){const selected=new Set(form.getAll("recipe_id").map(String));database.exec("BEGIN IMMEDIATE");try{database.prepare("DELETE FROM collection_recipes WHERE collection_id=?").run(id);const insert=database.prepare("INSERT INTO collection_recipes(collection_id,recipe_id,position) VALUES (?,?,?)");[...selected].forEach((recipeId,index)=>insert.run(id,recipeId,index+1));database.exec("COMMIT");}catch(cause){database.exec("ROLLBACK");throw cause;}}
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipe-books/${id}/`,303);
|
||||
}catch(cause){error=cause instanceof Error?cause.message:"Unable to save recipe book.";}
|
||||
const book=database.prepare("SELECT * FROM collections WHERE id=? AND deleted_at IS NULL").get(id) as any;if(!book){database.close();return new Response("Recipe book not found",{status:404});}
|
||||
const recipes=database.prepare("SELECT r.id,r.title,cr.position,cr.recipe_id IS NOT NULL included FROM recipes r LEFT JOIN collection_recipes cr ON cr.recipe_id=r.id AND cr.collection_id=? WHERE r.deleted_at IS NULL ORDER BY coalesce(cr.position,999999),r.title").all(id) as any[];
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../../lib/database";
|
||||
|
||||
const id = Astro.params.id!;
|
||||
const editing = !readOnlyMode && Astro.url.searchParams.get("edit") === "1";
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
let error = "";
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "save");
|
||||
|
||||
if (intent === "save" || intent === "details") {
|
||||
const name = String(form.get("name") ?? "").trim();
|
||||
const description = String(form.get("description") ?? "").trim() || null;
|
||||
if (!name) throw new Error("Name is required.");
|
||||
database.prepare("UPDATE collections SET name=?, description=? WHERE id=?").run(name, description, id);
|
||||
}
|
||||
|
||||
if (intent === "save" || intent === "membership") {
|
||||
const selected = new Set(form.getAll("recipe_id").map(String));
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
database.prepare("DELETE FROM collection_recipes WHERE collection_id=?").run(id);
|
||||
const insert = database.prepare("INSERT INTO collection_recipes(collection_id, recipe_id, position) VALUES (?, ?, ?)");
|
||||
[...selected].forEach((recipeId, index) => insert.run(id, recipeId, index + 1));
|
||||
database.exec("COMMIT");
|
||||
} catch (cause) {
|
||||
database.exec("ROLLBACK");
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipe-books/${id}/`, 303);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to save recipe book.";
|
||||
}
|
||||
}
|
||||
|
||||
const book = database.prepare("SELECT * FROM collections WHERE id=? AND deleted_at IS NULL").get(id) as any;
|
||||
if (!book) {
|
||||
database.close();
|
||||
return new Response("Recipe book not found", { status: 404 });
|
||||
}
|
||||
|
||||
const recipes = database.prepare(`
|
||||
SELECT r.id, r.title, cr.position, (cr.recipe_id IS NOT NULL) AS included
|
||||
FROM recipes r
|
||||
LEFT JOIN collection_recipes cr ON cr.recipe_id = r.id AND cr.collection_id = ?
|
||||
WHERE r.deleted_at IS NULL
|
||||
ORDER BY coalesce(cr.position, 999999), r.title
|
||||
`).all(id) as any[];
|
||||
|
||||
const includedRecipes = recipes.filter((r) => r.included);
|
||||
database.close();
|
||||
---
|
||||
<BaseLayout title={book.name} immersive><section class="entity-detail-shell recipe-book-detail"><DetailUtility/><header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=book">← Recipe books</a></p><h1>{book.name}</h1><p>{recipes.filter(x=>x.included).length} recipes</p></div>{!readOnlyMode&&<a class="edit-command" href={editing?`/app/recipe-books/${id}/`:`/app/recipe-books/${id}/?edit=1`}>{editing?"✓ Done":"✎ Edit"}</a>}</header>{error&&<p class="notice">{error}</p>}<main class="book-workspace">{editing&&<form method="post" class="book-details-form"><input type="hidden" name="intent" value="details"/><label><span>Name</span><input name="name" value={book.name} required/></label><label><span>Description</span><input name="description" value={book.description??""}/></label><button>Save details</button></form>}<form method="post" class:list={["book-membership",{"read-only":!editing}]}><input type="hidden" name="intent" value="membership"/><header><div><h2>Recipes</h2><p>{editing?"Choose the recipes included in this book.":book.description}</p></div>{editing&&<button>Save recipes</button>}</header>{recipes.filter(recipe=>editing||recipe.included).map(recipe=><label class="book-recipe-row">{editing&&<input type="checkbox" name="recipe_id" value={recipe.id} checked={recipe.included}/>}<span class="workspace-pill-icon recipe">▦</span><a href={`/app/recipes/${recipe.id}/`}><strong>{recipe.title}</strong></a></label>)}</form></main></section></BaseLayout>
|
||||
|
||||
<BaseLayout title={book.name} immersive>
|
||||
<div class="recipe-book-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="recipe-book-workspace">
|
||||
{error && <div class="notice book-notice">{error}</div>}
|
||||
|
||||
<header class="recipe-book-header">
|
||||
<div class="recipe-book-header-left">
|
||||
<nav class="recipe-book-breadcrumbs">
|
||||
<a href="/app/?type=book">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>Recipe books</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>{book.name}</h1>
|
||||
<div class="recipe-book-meta">
|
||||
<span class="recipe-book-count-badge">
|
||||
<span class="count-number">{includedRecipes.length}</span> {includedRecipes.length === 1 ? "recipe" : "recipes"}
|
||||
</span>
|
||||
{book.description && <span class="recipe-book-desc">{book.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div class="recipe-book-header-actions">
|
||||
<a
|
||||
class:list={["book-action-btn", { active: editing }]}
|
||||
href={editing ? `/app/recipe-books/${id}/` : `/app/recipe-books/${id}/?edit=1`}
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<span>Cancel</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
<span>Edit</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{editing ? (
|
||||
<form method="post" class="book-edit-form">
|
||||
<input type="hidden" name="intent" value="save" />
|
||||
|
||||
<section class="book-edit-card">
|
||||
<h2>Book Details</h2>
|
||||
<div class="book-field-group">
|
||||
<label>
|
||||
<span class="field-label">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={book.name}
|
||||
required
|
||||
placeholder="e.g. Signature Cocktails"
|
||||
class="book-input"
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="field-label">Description (optional)</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="2"
|
||||
placeholder="Add context or notes about this recipe collection..."
|
||||
class="book-textarea"
|
||||
>{book.description ?? ""}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="book-edit-card">
|
||||
<div class="book-edit-card-header">
|
||||
<div>
|
||||
<h2>Select Recipes</h2>
|
||||
<p class="section-subtitle">
|
||||
Choose the recipes included in this book ({includedRecipes.length} currently selected)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="book-recipe-checklist">
|
||||
{recipes.map((recipe) => (
|
||||
<label class="book-checklist-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="recipe_id"
|
||||
value={recipe.id}
|
||||
checked={recipe.included}
|
||||
class="book-checkbox"
|
||||
/>
|
||||
<span class="book-recipe-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" transform="scale(1.1, 1.1) translate(4px, 3.5px)"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="book-checklist-label">
|
||||
<strong>{recipe.title}</strong>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="book-edit-actions">
|
||||
<button type="submit" class="book-save-btn">Save changes</button>
|
||||
<a href={`/app/recipe-books/${id}/`} class="book-cancel-link">Cancel</a>
|
||||
</footer>
|
||||
</form>
|
||||
) : (
|
||||
<section class="book-view-section">
|
||||
{includedRecipes.length > 0 ? (
|
||||
<div class="book-directory-table">
|
||||
<div class="book-directory-toolbar">
|
||||
<span class="book-toolbar-title">Included Recipes</span>
|
||||
<span class="book-toolbar-count">{includedRecipes.length} {includedRecipes.length === 1 ? "recipe" : "recipes"}</span>
|
||||
</div>
|
||||
<div class="book-recipe-list">
|
||||
{includedRecipes.map((recipe) => (
|
||||
<div class="book-recipe-row">
|
||||
<span class="book-recipe-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" transform="scale(1.1, 1.1) translate(4px, 3.5px)"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="book-recipe-info">
|
||||
<a href={`/app/recipes/${recipe.id}/`} class="book-recipe-title">
|
||||
{recipe.title}
|
||||
</a>
|
||||
</div>
|
||||
<a href={`/app/recipes/${recipe.id}/`} class="book-recipe-arrow" aria-label={`View ${recipe.title}`}>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="book-empty-state">
|
||||
<span class="empty-icon">▣</span>
|
||||
<h3>No recipes in this book yet</h3>
|
||||
<p>Organize your recipes by adding them to this book.</p>
|
||||
{!readOnlyMode && (
|
||||
<a href={`/app/recipe-books/${id}/?edit=1`} class="book-add-recipes-btn">
|
||||
✎ Add recipes
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,8 +1,93 @@
|
||||
---
|
||||
export const prerender=false;
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { openDatabase,refreshSiteProjection } from "../../../../lib/database";
|
||||
let error:string|undefined;
|
||||
if(Astro.request.method==="POST"){const database=openDatabase({readOnly:false});if(!database)return Astro.redirect("/app/?error=database-missing",303);try{const form=await Astro.request.formData();const name=String(form.get("name")??"").trim();const description=String(form.get("description")??"").trim()||null;if(!name)throw new Error("Name is required.");const base=name.toLocaleLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g,"_").replace(/^_|_$/g,"")||"recipe_book";let id=base,suffix=2;while(database.prepare("SELECT 1 FROM collections WHERE id=?").get(id))id=`${base}_${suffix++}`;database.prepare("INSERT INTO collections(id,name,description,source_json) VALUES (?,?,?,'{}')").run(id,name,description);refreshSiteProjection(database);database.close();return Astro.redirect("/app/?type=book",303);}catch(cause){error=cause instanceof Error?cause.message:"Unable to create recipe book.";database.close();}}
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../../lib/database";
|
||||
|
||||
let error: string | undefined;
|
||||
|
||||
if (Astro.request.method === "POST") {
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||||
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const name = String(form.get("name") ?? "").trim();
|
||||
const description = String(form.get("description") ?? "").trim() || null;
|
||||
if (!name) throw new Error("Name is required.");
|
||||
|
||||
const base = name.toLocaleLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "recipe_book";
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (database.prepare("SELECT 1 FROM collections WHERE id=?").get(id)) {
|
||||
id = `${base}_${suffix++}`;
|
||||
}
|
||||
|
||||
database.prepare("INSERT INTO collections(id, name, description, source_json) VALUES (?, ?, ?, '{}')").run(id, name, description);
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipe-books/${id}/`, 303);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to create recipe book.";
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
---
|
||||
<BaseLayout title="New recipe book"><section class="shell page-heading"><p class="eyebrow"><a href="/app/?type=book">Recipe books</a></p><h1>New recipe book</h1><p>Create a collection for organizing recipes.</p></section><section class="shell create-entity"><form method="post" class="editor-form"><fieldset><legend>Recipe book details</legend>{error&&<div class="notice">{error}</div>}<label><span>Name</span><input name="name" required autofocus /></label><label><span>Description</span><textarea name="description" rows="4"></textarea></label></fieldset><div class="editor-actions"><button>Create recipe book</button><a href="/app/?type=book">Cancel</a></div></form></section></BaseLayout>
|
||||
|
||||
<BaseLayout title="New recipe book" immersive>
|
||||
<div class="recipe-book-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="recipe-book-workspace">
|
||||
{error && <div class="notice book-notice">{error}</div>}
|
||||
|
||||
<header class="recipe-book-header">
|
||||
<div class="recipe-book-header-left">
|
||||
<nav class="recipe-book-breadcrumbs">
|
||||
<a href="/app/?type=book">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>Recipe books</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>New recipe book</h1>
|
||||
<p class="recipe-book-subtitle">Create a collection for organizing recipes.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form method="post" class="book-edit-form">
|
||||
<section class="book-edit-card">
|
||||
<h2>Recipe Book Details</h2>
|
||||
<div class="book-field-group">
|
||||
<label>
|
||||
<span class="field-label">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="e.g. Pastry & Bakes, Cocktail Program"
|
||||
class="book-input"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="field-label">Description (optional)</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="3"
|
||||
placeholder="Describe what belongs in this recipe book..."
|
||||
class="book-textarea"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="book-edit-actions">
|
||||
<button type="submit" class="book-save-btn">Create recipe book</button>
|
||||
<a href="/app/?type=book" class="book-cancel-link">Cancel</a>
|
||||
</footer>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,9 +1,53 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
import fs from "node:fs"; import path from "node:path"; import YAML from "yaml";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro"; import PurchasingReview from "../../../components/PurchasingReview";
|
||||
export const prerender = false;
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../components/DetailUtility.astro";
|
||||
import PurchasingReview from "../../../components/PurchasingReview";
|
||||
import { ingredients } from "../../../lib/data";
|
||||
const file=path.resolve(process.cwd(),"generated/receipt-product-candidates.yaml"); const data=fs.existsSync(file)?YAML.parse(fs.readFileSync(file,"utf8")):null;
|
||||
const ingredientOptions=[...ingredients.values()].map(({id,name})=>({id,name})).sort((a,b)=>a.name.localeCompare(b.name));
|
||||
|
||||
const file = path.resolve(process.cwd(), "generated/receipt-product-candidates.yaml");
|
||||
const data = fs.existsSync(file) ? YAML.parse(fs.readFileSync(file, "utf8")) : null;
|
||||
const ingredientOptions = [...ingredients.values()]
|
||||
.map(({ id, name }) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
---
|
||||
<BaseLayout title="Purchasing review"><section class="shell page-heading"><p class="eyebrow">Local data tool</p><h1>Receipt product review</h1><p>Link actual Walmart and Sam's Club products to canonical ingredients.</p></section><section class="shell section-block">{data?<PurchasingReview client:load products={data.products} ingredients={ingredientOptions}/>:<div class="notice">Run <code>scripts/receipt-products propose</code>, then rebuild.</div>}</section></BaseLayout>
|
||||
|
||||
<BaseLayout title="Purchasing review" immersive>
|
||||
<div class="purchasing-review-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="purchasing-review-workspace">
|
||||
<header class="purchasing-review-header">
|
||||
<nav class="purchasing-breadcrumbs">
|
||||
<a href="/app/">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
<span>All items</span>
|
||||
</a>
|
||||
</nav>
|
||||
<h1>Receipt product review</h1>
|
||||
<p class="purchasing-subtitle">Link Walmart and Sam's Club purchase products to canonical formulation ingredients.</p>
|
||||
</header>
|
||||
|
||||
{data ? (
|
||||
<PurchasingReview client:load products={data.products} ingredients={ingredientOptions} />
|
||||
) : (
|
||||
<div class="purchasing-notice-card">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>No candidates generated yet</strong>
|
||||
<p>Run <code>scripts/receipt-products propose</code> to parse receipt files and extract product candidates.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,15 +1,292 @@
|
||||
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(()=>products.filter(p=>{const key=`${p.supplier_id}:${p.supplier_sku}`;return p.name.toLowerCase().includes(query.toLowerCase())&&(!unresolved||!(key in decisions))}),[products,query,unresolved,decisions]);
|
||||
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)};
|
||||
return <section><div class="review-toolbar"><div><strong>{Object.keys(decisions).length} / {products.length}</strong> reviewed<small>Products without explicit package sizes cannot be imported yet</small></div><input type="search" placeholder="Filter products" value={query} onInput={e=>setQuery(e.currentTarget.value)}/><label><input type="checkbox" checked={unresolved} onChange={e=>setUnresolved(e.currentTarget.checked)}/> Unresolved only</label><button onClick={download}>Export decisions</button></div>
|
||||
{visible.map(product=>{const key=`${product.supplier_id}:${product.supplier_sku}`;return <fieldset class="candidate-card" key={key}><legend>{product.name}</legend><p class="product-meta">{product.supplier_id.replace("_"," ")} · SKU {product.supplier_sku} · {product.package?`${product.package.quantity} ${product.package.unit_id}`:"package unknown"} · latest ${product.prices.at(-1)?.amount.toFixed(2)}</p>{product.ingredient_candidates.map(candidate=><label class="candidate-choice" key={candidate.ingredient_id}><input type="radio" name={key} checked={decisions[key]===candidate.ingredient_id} onChange={()=>choose(key,candidate.ingredient_id)}/><span><strong>{candidate.name}</strong><small>{candidate.ingredient_id} · {Math.round(candidate.score*100)}% token match</small></span></label>)}<label class="other-choice"><span>Choose another canonical ingredient</span><select value={decisions[key]??""} onChange={e=>choose(key,e.currentTarget.value||null)}><option value="">Select…</option>{ingredients.map(i=><option value={i.id}>{i.name} · {i.id}</option>)}</select></label><label class="candidate-choice none"><input type="radio" name={key} checked={key in decisions&&decisions[key]===null} onChange={()=>choose(key,null)}/><span><strong>Not a recipe ingredient</strong><small>Do not import this product</small></span></label>{product.url&&<p><a href={product.url} target="_blank" rel="noreferrer">Inspect product ↗</a></p>}</fieldset>})}
|
||||
</section>;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
+238
-7
@@ -647,8 +647,66 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .detail-actions-menu form { width:100% !important; max-width:100% !important; flex:none !important; min-width:0 !important; margin:0 !important; padding:0 !important; }
|
||||
.application-body .detail-actions-menu button { display:block; width:100% !important; max-width:100% !important; min-height:44px; padding:10px 20px; color:#050841; background:transparent; border:0; border-radius:0; cursor:pointer; font-size:15px; font-weight:500; text-align:left; white-space:nowrap; box-sizing:border-box; }
|
||||
.application-body .detail-actions-menu button:hover { background:#f1f5fe; }
|
||||
.application-body .book-workspace { width:min(900px,calc(100% - 48px)); min-height:600px; margin:0 auto; padding:42px 0; }.application-body .book-details-form { display:grid; grid-template-columns:1fr 1.4fr auto; align-items:end; gap:12px; margin-bottom:32px; }.application-body .book-details-form label { display:grid; gap:5px; }.application-body .book-details-form input { padding:9px; }.application-body .book-details-form button,.application-body .book-membership header button { padding:9px 14px; color:#fff; background:#3d5df6; border:0; }.application-body .book-membership > header { display:flex; justify-content:space-between; align-items:start; padding-bottom:18px; border-bottom:1px solid #eeeef3; }.application-body .book-membership h2 { margin:0; font-size:20px; }.application-body .book-membership p { margin:4px 0 0; color:#a5a9c1; }.application-body .book-recipe-row { display:grid; grid-template-columns:24px 28px 1fr; align-items:center; gap:12px; min-height:60px; border-bottom:1px solid #eeeef3; }.application-body .book-recipe-row > a { display:flex; flex-direction:column; text-decoration:none; }.application-body .book-recipe-row small { color:#a5a9c1; }
|
||||
.application-body .book-membership.read-only .book-recipe-row { grid-template-columns:28px minmax(0,1fr); }
|
||||
/* Recipe Books */
|
||||
.application-body .recipe-book-page { min-height: 100vh; background: #f3f3f3; padding-top: 48px; }
|
||||
.application-body .recipe-book-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .recipe-book-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 28px; }
|
||||
.application-body .recipe-book-breadcrumbs { margin-bottom: 8px; }
|
||||
.application-body .recipe-book-breadcrumbs a { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 14px; font-weight: 500; text-decoration: none; transition: color 0.15s ease; }
|
||||
.application-body .recipe-book-breadcrumbs a:hover { color: #202962; }
|
||||
.application-body .recipe-book-header h1 { margin: 0 0 6px; color: #050841; font-size: 28px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.application-body .recipe-book-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.application-body .recipe-book-count-badge { display: inline-flex; align-items: center; padding: 3px 10px; background: #fff; border: 1px solid #dfe3ec; border-radius: 999px; color: #687086; font-size: 13px; font-weight: 500; }
|
||||
.application-body .recipe-book-count-badge .count-number { color: #3d5df6; font-weight: 700; margin-right: 4px; }
|
||||
.application-body .recipe-book-desc, .application-body .recipe-book-subtitle { color: #8b93a7; font-size: 14px; font-weight: 400; margin: 0; }
|
||||
.application-body .recipe-book-header-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.application-body .book-action-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 16px; color: #3d5df6; background: #fff; border: 1px solid #dfe3ec; border-radius: 100px; font-size: 14px; font-weight: 500; text-decoration: none; box-shadow: 0 1px 2px rgba(5,8,65,0.04); transition: all 0.15s ease; }
|
||||
.application-body .book-action-btn:hover { background: #f7f9fd; border-color: #bcc6e5; }
|
||||
.application-body .book-action-btn.active { color: #687086; }
|
||||
.application-body .book-notice { margin-bottom: 20px; padding: 12px 16px; background: #fff1f1; border: 1px solid #f5cece; border-radius: 6px; color: #8d2e2e; font-size: 14px; }
|
||||
.application-body .book-directory-table { background: #fff; border: 1px solid #eef0f5; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.application-body .book-directory-toolbar { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 0 20px; background: #fbfbfb; border-bottom: 1px solid #f3f3f3; }
|
||||
.application-body .book-toolbar-title { color: #050841; font-size: 14px; font-weight: 600; }
|
||||
.application-body .book-toolbar-count { color: #8b93a7; font-size: 13px; font-weight: 400; }
|
||||
.application-body .book-recipe-list { display: flex; flex-direction: column; }
|
||||
.application-body .book-recipe-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) 36px; align-items: center; gap: 12px; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #f3f3f3; transition: background-color 0.12s ease; }
|
||||
.application-body .book-recipe-row:last-child { border-bottom: 0; }
|
||||
.application-body .book-recipe-row:hover { background: #fbfbfb; }
|
||||
.application-body .book-recipe-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: #3c4679; border-radius: 50%; color: #fff; }
|
||||
.application-body .book-recipe-title { color: #050841; font-size: 15px; font-weight: 500; text-decoration: none; transition: color 0.12s ease; }
|
||||
.application-body .book-recipe-title:hover { color: #3d5df6; }
|
||||
.application-body .book-recipe-arrow { display: grid; place-items: center; width: 28px; height: 28px; color: #a5a9c1; border-radius: 50%; text-decoration: none; transition: all 0.12s ease; }
|
||||
.application-body .book-recipe-row:hover .book-recipe-arrow { color: #3d5df6; background: #f1f5fe; }
|
||||
.application-body .book-empty-state, .application-body .archive-empty-state, .application-body .purchasing-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 64px 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 6px; text-align: center; }
|
||||
.application-body .book-empty-state .empty-icon { display: grid; place-items: center; width: 54px; height: 54px; margin-bottom: 16px; background: #f1f5fe; border-radius: 50%; color: #3d5df6; font-size: 24px; }
|
||||
.application-body .book-empty-state h3, .application-body .archive-empty-state h3, .application-body .purchasing-empty-state h3 { margin: 0 0 6px; color: #050841; font-size: 18px; font-weight: 600; }
|
||||
.application-body .book-empty-state p, .application-body .archive-empty-state p, .application-body .purchasing-empty-state p { margin: 0 0 20px; color: #8b93a7; font-size: 14px; max-width: 420px; }
|
||||
.application-body .book-add-recipes-btn, .application-body .archive-back-btn, .application-body .purchasing-reset-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 20px; background: #3d5df6; color: #fff; border: 0; border-radius: 100px; font-size: 14px; font-weight: 500; text-decoration: none; cursor: pointer; box-shadow: 0 2px 6px rgba(61, 93, 246, 0.28); transition: background 0.15s ease; }
|
||||
.application-body .book-add-recipes-btn:hover, .application-body .archive-back-btn:hover, .application-body .purchasing-reset-btn:hover { background: #2b4be0; }
|
||||
|
||||
/* Recipe Book Editing */
|
||||
.application-body .book-edit-form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.application-body .book-edit-card { padding: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.application-body .book-edit-card h2 { margin: 0 0 16px; color: #050841; font-size: 18px; font-weight: 600; }
|
||||
.application-body .book-edit-card-header { margin-bottom: 16px; }
|
||||
.application-body .section-subtitle { margin: 4px 0 0; color: #8b93a7; font-size: 14px; }
|
||||
.application-body .book-field-group { display: flex; flex-direction: column; gap: 16px; }
|
||||
.application-body .book-field-group label { display: flex; flex-direction: column; gap: 6px; }
|
||||
.application-body .field-label { color: #050841; font-size: 13px; font-weight: 600; }
|
||||
.application-body .book-input, .application-body .book-textarea { width: 100%; padding: 10px 14px; background: #fff; border: 1px solid #dfe3ec; border-radius: 4px; color: #050841; font-family: var(--meez-font-sans); font-size: 15px; font-weight: 400; outline: none; box-sizing: border-box; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
|
||||
.application-body .book-input:focus, .application-body .book-textarea:focus { border-color: #3d5df6; box-shadow: 0 0 0 2px rgba(61, 93, 246, 0.15); }
|
||||
.application-body .book-recipe-checklist { display: flex; flex-direction: column; gap: 4px; max-height: 480px; overflow-y: auto; padding-right: 4px; }
|
||||
.application-body .book-checklist-item { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 14px; background: #fff; border: 1px solid #f3f3f3; border-radius: 6px; cursor: pointer; transition: background 0.12s ease, border-color 0.12s ease; }
|
||||
.application-body .book-checklist-item:hover { background: #f7f9fd; border-color: #e4e7ed; }
|
||||
.application-body .book-checklist-item:has(.book-checkbox:checked) { background: #f1f5fe; border-color: #cfd5fa; }
|
||||
.application-body .book-checkbox { width: 18px; height: 18px; margin: 0; accent-color: #3d5df6; cursor: pointer; }
|
||||
.application-body .book-checklist-label { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||
.application-body .book-checklist-label strong { color: #050841; font-size: 14px; font-weight: 500; }
|
||||
.application-body .book-edit-actions { display: flex; align-items: center; gap: 16px; padding: 12px 0; }
|
||||
.application-body .book-save-btn { min-height: 40px; padding: 0 24px; background: #3d5df6; color: #fff; border: 0; border-radius: 100px; font-family: var(--meez-font-sans); font-size: 15px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 6px rgba(61, 93, 246, 0.28); transition: background 0.15s ease; }
|
||||
.application-body .book-save-btn:hover { background: #2b4be0; }
|
||||
.application-body .book-cancel-link { color: #687086; font-size: 14px; font-weight: 500; text-decoration: none; padding: 8px 12px; transition: color 0.12s ease; }
|
||||
.application-body .book-cancel-link:hover { color: #050841; }
|
||||
.application-body .recipe-additional-editor { width:min(800px,calc(100% - 76px)); margin:36px 38px; padding-top:28px; border-top:1px solid #eeeef3; }.application-body .recipe-additional-editor > form { display:grid; gap:14px; }.application-body .recipe-additional-editor label { display:grid; gap:5px; }.application-body .recipe-additional-editor input,.application-body .recipe-additional-editor textarea,.application-body .recipe-additional-editor select { padding:9px; }.application-body .recipe-additional-editor fieldset { display:grid; grid-template-columns:120px 120px 1fr; gap:10px; border:1px solid #eeeef3; }.application-body .recipe-additional-editor button { justify-self:start; padding:9px 14px; color:#fff; background:#3d5df6; border:0; }
|
||||
.application-body .nutrition-ingredient-list { margin-top:16px; border-top:1px solid #eeeef3; }
|
||||
.application-body .nutrition-list-heading { display:flex; justify-content:space-between; gap:20px; padding:14px 4px 10px; color:#202962; }
|
||||
@@ -670,11 +728,102 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .calculator-heading .input-with-unit > span { display:grid; place-items:center; height:100%; color:#647df8; border-left:1px solid #eeeef3; font-size:14px; font-weight:600; }
|
||||
.application-body .calculator-heading .recipe-scale-control select { width:100%; height:38px; padding:0 6px; color:#647df8; background:#fff; border:0; border-left:1px solid #eeeef3; border-radius:0; outline:0; font-size:13px; font-weight:600; cursor:pointer; text-align:center; }
|
||||
.application-body .workspace-pills > a.archive-link { margin-left:auto; color:#a5a9c1; background:transparent; border-color:transparent; }
|
||||
.application-body .archive-workspace h1 { margin:8px 0; font-size:28px; }
|
||||
.application-body .archive-workspace header p { color:#a5a9c1; }
|
||||
.application-body .archive-row { display:grid; grid-template-columns:28px 1fr auto; align-items:center; gap:12px; min-height:60px; border-bottom:1px solid #eeeef3; }
|
||||
.application-body .archive-row small { display:block; color:#a5a9c1; }
|
||||
.application-body .archive-row button { padding:7px 12px; color:#3d5df6; background:#fff; border:1px solid #cfd5fa; }
|
||||
|
||||
/* Archive Workspace */
|
||||
.application-body .archive-page { min-height: 100vh; background: #f3f3f3; padding-top: 48px; }
|
||||
.application-body .archive-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .archive-header { margin-bottom: 24px; }
|
||||
.application-body .archive-breadcrumbs { margin-bottom: 8px; }
|
||||
.application-body .archive-breadcrumbs a { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 14px; font-weight: 500; text-decoration: none; }
|
||||
.application-body .archive-header h1 { margin: 0 0 6px; color: #050841; font-size: 28px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.application-body .archive-subtitle { color: #8b93a7; font-size: 14px; margin: 0; }
|
||||
.application-body .archive-toolbar { margin-bottom: 16px; }
|
||||
.application-body .archive-filter-chips { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; }
|
||||
.application-body .archive-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 6px 14px; background: #fff; border: 1px solid #ececec; border-radius: 999px; color: rgba(0, 0, 0, 0.87); font-size: 14px; font-weight: 400; text-decoration: none; white-space: nowrap; transition: all 0.15s ease; }
|
||||
.application-body .archive-chip:hover { background: #fff; border-color: #cfd5fa; }
|
||||
.application-body .archive-chip.active { background: #DBE4FF; border-color: #DBE4FF; }
|
||||
.application-body .archive-chip .chip-count { color: #8b93a7; font-size: 12px; font-weight: 500; }
|
||||
.application-body .archive-table { background: #fff; border: 1px solid #eef0f5; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.application-body .archive-table-head { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 48px; padding: 0 20px; background: #fbfbfb; border-bottom: 1px solid #f3f3f3; color: #8b93a7; font-size: 13px; font-weight: 500; }
|
||||
.application-body .archive-table-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 180px 120px; align-items: center; min-height: 58px; padding: 0 20px; border-bottom: 1px solid #f3f3f3; transition: background-color 0.12s ease; }
|
||||
.application-body .archive-table-row:last-child { border-bottom: 0; }
|
||||
.application-body .archive-table-row:hover { background: #fbfbfb; }
|
||||
.application-body .archive-type-cell { display: flex; align-items: center; }
|
||||
.application-body .archive-name-cell { display: flex; flex-direction: column; min-width: 0; }
|
||||
.application-body .archive-item-title { color: #050841; font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.application-body .archive-item-type { color: #8b93a7; font-size: 12px; text-transform: capitalize; }
|
||||
.application-body .archive-date-badge { color: #8b93a7; font-size: 13px; }
|
||||
.application-body .archive-action-cell { display: flex; justify-content: flex-end; }
|
||||
.application-body .archive-restore-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 12px; background: #fff; border: 1px solid #dfe3ec; border-radius: 6px; color: #3d5df6; font-family: var(--meez-font-sans); font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; }
|
||||
.application-body .archive-restore-btn:hover { background: #f1f5fe; border-color: #3d5df6; }
|
||||
.application-body .empty-icon-circle { display: grid; place-items: center; width: 56px; height: 56px; margin-bottom: 16px; background: #f1f5fe; border-radius: 50%; color: #3d5df6; }
|
||||
|
||||
/* Purchasing Review Tool */
|
||||
.application-body .purchasing-review-page { min-height: 100vh; background: #f3f3f3; padding-top: 48px; }
|
||||
.application-body .purchasing-review-workspace { width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 28px 0 64px; }
|
||||
.application-body .purchasing-review-header { margin-bottom: 24px; }
|
||||
.application-body .purchasing-breadcrumbs { margin-bottom: 8px; }
|
||||
.application-body .purchasing-breadcrumbs a { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 14px; font-weight: 500; text-decoration: none; }
|
||||
.application-body .purchasing-review-header h1 { margin: 0 0 6px; color: #050841; font-size: 28px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.application-body .purchasing-subtitle { color: #8b93a7; font-size: 14px; margin: 0; }
|
||||
.application-body .purchasing-review-toolbar { position: sticky; top: 52px; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; margin-bottom: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 8px; box-shadow: 0 2px 8px rgba(5,8,65,0.06); }
|
||||
.application-body .purchasing-review-stats { display: flex; flex-direction: column; gap: 2px; }
|
||||
.application-body .stats-counter { display: flex; align-items: center; gap: 6px; }
|
||||
.application-body .stats-count { color: #3d5df6; font-size: 16px; font-weight: 700; }
|
||||
.application-body .stats-label { color: #050841; font-size: 14px; font-weight: 500; }
|
||||
.application-body .stats-hint { color: #8b93a7; font-size: 12px; }
|
||||
.application-body .purchasing-review-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.application-body .purchasing-search-box { display: flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 12px; background: #fbfbfb; border: 1px solid #dfe3ec; border-radius: 4px; }
|
||||
.application-body .purchasing-search-box .search-icon { color: #8283a0; flex: none; }
|
||||
.application-body .purchasing-search-box input { border: 0; background: transparent; outline: none; font-family: var(--meez-font-sans); font-size: 14px; color: #050841; min-width: 180px; }
|
||||
.application-body .search-clear-btn { background: transparent; border: 0; color: #a5a9c1; font-size: 18px; cursor: pointer; padding: 0 2px; }
|
||||
.application-body .purchasing-filter-toggle { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
|
||||
.application-body .purchasing-filter-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.application-body .toggle-track { display: inline-block; width: 34px; height: 18px; background: #cbd1dc; border-radius: 12px; position: relative; transition: background 0.15s ease; }
|
||||
.application-body .toggle-thumb { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: #fff; border-radius: 50%; transition: transform 0.15s ease; }
|
||||
.application-body .purchasing-filter-toggle input:checked + .toggle-track { background: #3d5df6; }
|
||||
.application-body .purchasing-filter-toggle input:checked + .toggle-track .toggle-thumb { transform: translateX(16px); }
|
||||
.application-body .toggle-label { color: #050841; font-size: 13px; font-weight: 500; }
|
||||
.application-body .purchasing-export-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 16px; background: #3d5df6; color: #fff; border: 0; border-radius: 100px; font-family: var(--meez-font-sans); font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 6px rgba(61, 93, 246, 0.28); transition: background 0.15s ease; }
|
||||
.application-body .purchasing-export-btn:hover { background: #2b4be0; }
|
||||
.application-body .purchasing-products-grid { display: flex; flex-direction: column; gap: 20px; }
|
||||
.application-body .purchasing-card { padding: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); transition: border-color 0.15s ease; }
|
||||
.application-body .purchasing-card.resolved { border-left: 3px solid #40b49a; }
|
||||
.application-body .purchasing-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid #edf0f5; }
|
||||
.application-body .purchasing-badges { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
|
||||
.application-body .supplier-badge { display: inline-flex; padding: 2px 8px; background: #eef2ff; border-radius: 4px; color: #3d5df6; font-size: 12px; font-weight: 600; text-transform: capitalize; }
|
||||
.application-body .supplier-badge.walmart { background: #e6f1fc; color: #0071dc; }
|
||||
.application-body .supplier-badge.sams_club { background: #e1f5fe; color: #0067a0; }
|
||||
.application-body .sku-badge, .application-body .package-badge { display: inline-flex; padding: 2px 8px; background: #f7f7f8; border: 1px solid #ececec; border-radius: 4px; color: #687086; font-size: 12px; font-weight: 500; }
|
||||
.application-body .price-badge { display: inline-flex; padding: 2px 8px; background: #e8f5e9; border-radius: 4px; color: #2e7d32; font-size: 12px; font-weight: 700; }
|
||||
.application-body .purchasing-product-name { margin: 0; color: #050841; font-size: 18px; font-weight: 600; }
|
||||
.application-body .purchasing-external-link { display: inline-flex; align-items: center; gap: 4px; color: #3d5df6; font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; }
|
||||
.application-body .purchasing-external-link:hover { text-decoration: underline; }
|
||||
.application-body .candidates-heading { display: block; margin-bottom: 12px; color: #687086; font-size: 13px; font-weight: 600; }
|
||||
.application-body .candidate-options-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.application-body .candidate-option-card { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 10px 16px; background: #fff; border: 1px solid #dfe3ec; border-radius: 6px; cursor: pointer; transition: all 0.12s ease; }
|
||||
.application-body .candidate-option-card:hover { background: #f7f9fd; border-color: #bcc6e5; }
|
||||
.application-body .candidate-option-card.selected { background: #f1f5fe; border-color: #3d5df6; box-shadow: 0 0 0 1px #3d5df6; }
|
||||
.application-body .candidate-radio { display: none; }
|
||||
.application-body .candidate-custom-radio { display: grid; place-items: center; width: 18px; height: 18px; border: 2px solid #a5a9c1; border-radius: 50%; flex: none; transition: all 0.12s ease; }
|
||||
.application-body .candidate-custom-radio i { width: 8px; height: 8px; background: transparent; border-radius: 50%; transition: background 0.12s ease; }
|
||||
.application-body .candidate-option-card.selected .candidate-custom-radio { border-color: #3d5df6; }
|
||||
.application-body .candidate-option-card.selected .candidate-custom-radio i { background: #3d5df6; }
|
||||
.application-body .candidate-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.application-body .candidate-name { color: #050841; font-size: 15px; font-weight: 500; }
|
||||
.application-body .candidate-meta { color: #8b93a7; font-size: 12px; }
|
||||
.application-body .candidate-score-badge { display: inline-flex; padding: 2px 8px; background: #f1f5fe; border-radius: 12px; color: #3d5df6; font-size: 12px; font-weight: 600; }
|
||||
.application-body .candidate-score-badge.high { background: #e8f5e9; color: #2e7d32; }
|
||||
.application-body .candidate-option-card.none { border-style: dashed; }
|
||||
.application-body .candidate-other-option { margin: 4px 0; padding: 6px 0; }
|
||||
.application-body .other-select-label { display: flex; align-items: center; gap: 12px; }
|
||||
.application-body .other-select-text { color: #687086; font-size: 13px; font-weight: 500; white-space: nowrap; }
|
||||
.application-body .purchasing-select { flex: 1; min-height: 38px; padding: 6px 12px; background: #fff; border: 1px solid #dfe3ec; border-radius: 4px; color: #050841; font-family: var(--meez-font-sans); font-size: 14px; outline: none; cursor: pointer; }
|
||||
.application-body .purchasing-select:focus { border-color: #3d5df6; }
|
||||
.application-body .purchasing-notice-card { display: flex; align-items: flex-start; gap: 14px; padding: 24px; background: #fff; border: 1px solid #eef0f5; border-radius: 6px; color: #050841; }
|
||||
.application-body .purchasing-notice-card svg { color: #3d5df6; flex: none; margin-top: 2px; }
|
||||
.application-body .purchasing-notice-card strong { display: block; margin-bottom: 4px; font-size: 16px; }
|
||||
.application-body .purchasing-notice-card p { margin: 0; color: #8b93a7; font-size: 14px; }
|
||||
.application-body .ingredient-merge { margin:24px 0; padding:16px; border:1px solid #f0d6d6; }
|
||||
.application-body .ingredient-merge summary { color:#a73838; cursor:pointer; font-weight:600; }
|
||||
.application-body .ingredient-merge form { display:flex; gap:8px; }
|
||||
@@ -4091,4 +4240,86 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.bulk-dialog-close { top: 16px !important; right: 18px !important; }
|
||||
.bulk-ingredient-dialog footer { gap: 12px !important; }
|
||||
.bulk-ingredient-dialog .bulk-submit { min-width: 0 !important; flex: 1 !important; }
|
||||
|
||||
/* Recipe Books Mobile */
|
||||
.application-body .recipe-book-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
padding: 20px 0 40px !important;
|
||||
}
|
||||
.application-body .recipe-book-header {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
gap: 16px !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
.application-body .recipe-book-header-actions {
|
||||
align-self: flex-start !important;
|
||||
}
|
||||
.application-body .book-recipe-row {
|
||||
grid-template-columns: 32px minmax(0, 1fr) 28px !important;
|
||||
padding: 0 14px !important;
|
||||
min-height: 52px !important;
|
||||
}
|
||||
.application-body .book-directory-toolbar {
|
||||
padding: 0 14px !important;
|
||||
}
|
||||
|
||||
/* Archive Mobile */
|
||||
.application-body .archive-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
padding: 20px 0 40px !important;
|
||||
}
|
||||
.application-body .archive-table-head {
|
||||
display: none !important;
|
||||
}
|
||||
.application-body .archive-table-row {
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto !important;
|
||||
gap: 10px !important;
|
||||
padding: 10px 14px !important;
|
||||
min-height: 56px !important;
|
||||
}
|
||||
.application-body .archive-date-cell {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Purchasing Review Mobile */
|
||||
.application-body .purchasing-review-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
padding: 20px 0 40px !important;
|
||||
}
|
||||
.application-body .purchasing-review-toolbar {
|
||||
position: static !important;
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
gap: 14px !important;
|
||||
padding: 14px !important;
|
||||
}
|
||||
.application-body .purchasing-review-actions {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
gap: 10px !important;
|
||||
}
|
||||
.application-body .purchasing-search-box {
|
||||
width: 100% !important;
|
||||
}
|
||||
.application-body .purchasing-search-box input {
|
||||
min-width: 0 !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
.application-body .purchasing-card {
|
||||
padding: 16px !important;
|
||||
}
|
||||
.application-body .purchasing-card-header {
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 10px !important;
|
||||
}
|
||||
.application-body .other-select-label {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user