feat: implement barebones inventory, costing parity, navigation fixes, and Meez design polish
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# Formulation Documentation & Knowledge Base
|
||||
|
||||
Welcome to the Formulation Help Center. This documentation explains the architecture, business logic, and operational workflows for managing recipes, ingredients, costs, units of measure, inventory, and archives.
|
||||
|
||||
---
|
||||
|
||||
## Knowledge Base Directory
|
||||
|
||||
### 🚀 Getting Started
|
||||
- [Workspace Navigation & Global Search](./getting-started/workspace-navigation.md): Navigating workspaces, filtering catalogs, global search, and keyboard shortcuts.
|
||||
|
||||
### 🍳 Recipes & Formulas
|
||||
- [Scaling, Batching & Yield Calculations](./recipes/scaling-and-yields.md): Interactive scaling, yield conversions, weight-based auto-yields, and portion control.
|
||||
- [Baker's & Standard Percentages](./recipes/bakers-percentages.md): Flour basis calculation, dynamic target weights, and formula ratios.
|
||||
- [Sub-recipes & Prep Methods](./recipes/sub-recipes-and-prep.md): Nesting recipes as ingredients, prep instructions, headers, notes, and equipment tracking.
|
||||
|
||||
### 🌿 Ingredients & Units of Measure
|
||||
- [Units of Measure & Custom Equivalencies](./ingredients/units-and-equivalencies.md): Dimensional systems (mass, volume, count), canonical conversions, density measures, and USDA nutrition mapping.
|
||||
|
||||
### 💰 Costing & Purchasing
|
||||
- [Purchase Items, Pack Sizes & Recipe Costing](./costing/purchase-items-and-costing.md): Invoices, pack configurations, yield factors, price history, food cost per batch, and cost per serving.
|
||||
|
||||
### 📦 Inventory Management
|
||||
- [Inventory Steps for Success (5-Week Implementation Roadmap)](./inventory/inventory-calendar-steps-for-success.md): Full operational guide for going from initial setup to first live period-end inventory count.
|
||||
- [Count Sheets & Storage Locations](./inventory/count-sheets-and-locations.md): Location-specific sheet-to-shelf counting, on-hand inputs, and live extended valuations.
|
||||
|
||||
### 🗄️ Archive & Trash Lifecycle
|
||||
- [Archive & Lifecycle Management](./archive/lifecycle-and-restoration.md): Soft-deletion, catalog filtering, multi-item batch restore, and safe permanent deletion guards.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Archive & Lifecycle Management
|
||||
|
||||
Formulation implements a two-stage deletion lifecycle (Soft Delete $\rightarrow$ Hard Delete) with automated dependency safeguards to protect culinary data integrity.
|
||||
|
||||
---
|
||||
|
||||
## 1. Soft Deletion (Archiving)
|
||||
|
||||
- When an ingredient, recipe, or recipe book is deleted, it is **soft-deleted** (`deleted_at` timestamp recorded) rather than purged immediately.
|
||||
- Archived items are immediately hidden from active searches, auto-complete dropdowns, and directory views.
|
||||
- Active recipes that historically reference an archived ingredient remain intact without breaking calculations.
|
||||
|
||||
---
|
||||
|
||||
## 2. Archive Workspace (`/app/archive/`)
|
||||
|
||||
The Archive workspace allows viewing and managing all removed items:
|
||||
- **Filter by Entity**: Filter by *All*, *Recipes*, *Ingredients*, or *Recipe Books*.
|
||||
- **Multi-Item Batch Selection**: Select multiple items using checkboxes to perform bulk actions.
|
||||
- **Batch Restore**: Instantly restore selected items back to the active catalog.
|
||||
- **Safe Permanent Deletion**:
|
||||
- Permanently purges items from the database.
|
||||
- **Dependency Safeguard**: Formulation automatically verifies whether an item is still referenced by any active recipe or sub-recipe. If dependencies exist, hard deletion is blocked with a clear warning explaining where the item is currently used.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Purchase Items, Pack Sizes & Recipe Costing
|
||||
|
||||
Accurate recipe food costing relies on mapping real-world vendor purchase packages to canonical ingredients.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purchase Items & Pack Configurations
|
||||
|
||||
A **Purchase Item** represents a commercial package purchased from a vendor or distributor:
|
||||
- **Pack Size & Unit**: e.g., `50 lb Bag`, `6 x 1 Gallon Case`, `16 oz Container`.
|
||||
- **Cost**: Total package purchase price (e.g. `$24.50`).
|
||||
- **Yield Factor (%)**: The usable portion percentage after trimming or prep (e.g. 85% usable yield on trimmed beef tenderloin, 100% on flour).
|
||||
- **Unit Cost**: Automatically computed per base unit (e.g. `$0.00108 / gram` or `$0.49 / lb`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Recipe Food Costing Breakdown
|
||||
|
||||
When viewing a recipe's **Cost** tab:
|
||||
1. **Line Cost**: Each ingredient's line item cost is calculated as:
|
||||
$$\text{Line Cost} = \frac{\text{Quantity} \times \text{Unit Cost}}{\text{Yield Factor}}$$
|
||||
2. **Total Batch Cost**: The sum of all line item costs for the batch.
|
||||
3. **Cost per Serving**: Total Batch Cost divided by total yield servings.
|
||||
4. **Food Cost % (Target Selling Price)**:
|
||||
$$\text{Suggested Price} = \frac{\text{Cost per Serving}}{\text{Target Food Cost \%}}$$
|
||||
@@ -0,0 +1,36 @@
|
||||
# Workspace Navigation & Global Search
|
||||
|
||||
Formulation provides a streamlined, fast, centralized directory for managing all culinary data across your operation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory Workspace & Workspace Pills
|
||||
|
||||
The home directory (`/app/`) categorizes items into distinct workspaces using top pill badges:
|
||||
|
||||
- **Recipes** (Blue badge): Standalone formulas, prep recipes, and batch formulations.
|
||||
- **Ingredients** (Green badge): Raw culinary ingredients, allergens, density conversions, and supplier links.
|
||||
- **Recipe Books** (Purple badge): Curated collections and menus of recipes (e.g. *Dinner Menu*, *Cocktails*, *Bakery Line*).
|
||||
- **Purchase Items** (Cyan badge): Commercial vendor packages, invoice pack sizes, prices, and vendor SKUs.
|
||||
- **Inventory** (Teal badge): Active and past inventory count sessions with on-hand valuations.
|
||||
- **Archive** (Neutral link): Soft-deleted items ready for restoration or permanent purge.
|
||||
|
||||
---
|
||||
|
||||
## 2. Global Search & Autocompletion
|
||||
|
||||
- **Omnibox Search**: Search across recipe titles, ingredient names, aliases, and purchase items simultaneously.
|
||||
- **Type Filtering**: Narrow search results by specific entity type directly from the search dropdown filter.
|
||||
- **Keyboard Navigation**:
|
||||
- <kbd>Tab</kbd> / <kbd>Arrow Down</kbd>: Highlight matching search candidates.
|
||||
- <kbd>Enter</kbd>: Open the selected recipe or ingredient detail card immediately.
|
||||
- <kbd>Escape</kbd>: Clear search and close active popups.
|
||||
|
||||
---
|
||||
|
||||
## 3. Detail Utility Bar
|
||||
|
||||
Every single item detail page features a fixed top utility bar containing:
|
||||
- **Workspace Breadcrumbs**: Direct navigation back to the active directory workspace.
|
||||
- **Global Search**: Search and jump to other items without returning to the home screen.
|
||||
- **New Action Button (`+ New`)**: Quick creation modal for recipes, ingredients, recipe books, or count sessions from anywhere in the app.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Units of Measure & Custom Equivalencies
|
||||
|
||||
Formulation maintains a rigorous, multi-dimensional unit conversion engine that enforces physical dimensional rules while supporting culinary volume-to-weight equivalencies.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dimensional Systems
|
||||
|
||||
Every unit belongs to a fundamental physical dimension:
|
||||
- **Mass** (Base unit: `gram`): `gram`, `kilogram`, `pound`, `ounce_mass`.
|
||||
- **Volume** (Base unit: `milliliter`): `milliliter`, `liter`, `cup_us` (240 mL legal), `tablespoon_us`, `teaspoon_us`, `fluid_ounce_us`.
|
||||
- **Count** (Base unit: `each`): `each`, `clove`, `head`, `bunch`.
|
||||
- **Temperature** (Affine scale): `fahrenheit`, `celsius`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Density & Ingredient-Specific UoM Equivalencies
|
||||
|
||||
Because ingredients possess different bulk densities (e.g. 1 cup of all-purpose flour = ~120g, whereas 1 cup of honey = ~340g), volume-to-mass conversions require density records.
|
||||
|
||||
### A. UoM Equivalencies Panel
|
||||
- On each ingredient page, the **UoM Equivalency** tab allows defining custom portion measurements:
|
||||
- *Example*: `1 cup = 125 g`
|
||||
- *Example*: `1 medium apple = 182 g`
|
||||
- *Example*: `1 clove garlic = 3 g`
|
||||
|
||||
### B. Resolution Precedence
|
||||
1. **Reviewed Portions / Measures**: Checked first for an exact unit match (e.g. `cup` or `each`).
|
||||
2. **Bulk Density Measurements**: Checked if converting between standard volume and mass dimensions.
|
||||
3. **Canonical Unit Factor**: Applied for within-dimension conversions (e.g. `lb` to `oz`).
|
||||
@@ -0,0 +1,24 @@
|
||||
# Count Sheets & Storage Locations
|
||||
|
||||
Inventory in Formulation is built for high-speed, sheet-to-shelf counting across physical storage locations.
|
||||
|
||||
---
|
||||
|
||||
## 1. Storage Locations
|
||||
|
||||
Organize your kitchen into logical physical zones:
|
||||
- **Walk-in Cooler**: Dairy, produce, raw proteins, prepped batch items.
|
||||
- **Dry Storage**: Flours, grains, spices, oils, canned goods.
|
||||
- **Freezer**: Frozen stocks, puff pastry, frozen proteins.
|
||||
- **Bar / Front of House**: Spirits, syrups, mixers, garnishes.
|
||||
- **Line Stations**: Sauté station drawers, prep table bins.
|
||||
|
||||
---
|
||||
|
||||
## 2. Conducting an Inventory Count
|
||||
|
||||
1. **Start a Count Session**: Open `/app/inventory/` and click **+ New Count Session**.
|
||||
2. **Sheet-to-Shelf Counting**: Open the count sheet and filter by physical location. Items appear in the exact physical order of your storage shelves.
|
||||
3. **Enter On-Hand Quantities**: Type counted units (e.g. `4.5` bags, `12` each, `25` lbs).
|
||||
4. **Live Valuation**: Formulation instantly computes the **Extended Value ($)** for each item based on current vendor purchase costs.
|
||||
5. **Complete & Finalize**: Submit the count to freeze period-end inventory valuation.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Inventory Steps for Success
|
||||
|
||||
A structured 5-week roadmap to build out recipes, configure purchasing units and costs, organize location-specific count sheets, test inventory counting, and successfully conduct your first live inventory.
|
||||
|
||||
---
|
||||
|
||||
## 5-Week Roadmap Overview
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title Inventory Onboarding & Setup Roadmap
|
||||
dateFormat X
|
||||
axisFormat Day %d
|
||||
section Week 1
|
||||
Recipes & Ingredients Setup :active, 1, 7
|
||||
section Week 2
|
||||
Purchasing Units & Costs :2, 14
|
||||
section Week 3
|
||||
Location Count Sheets :3, 21
|
||||
section Week 4
|
||||
Dry-Run Test Counts :4, 28
|
||||
section Week 5
|
||||
First Live Inventory & Analytics :5, 35
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Week 1: Build Out Recipes & Ingredients Tables
|
||||
|
||||
> **Week 1 Goal**: Your complete recipe database and canonical ingredient list are populated and ready for kitchen use.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Define the scope of prep items, sub-recipes, and raw ingredients to be tracked. |
|
||||
| **Tuesday** | **Start with Prep Recipes** | Begin by entering your prep recipes and sub-recipes. As you add prep recipes, your canonical ingredient list will automatically populate. |
|
||||
| **Wednesday** | **Audit & Merge Ingredients** | Review your ingredient catalog. Identify duplicates or near-duplicates (e.g. "kosher salt" vs "salt kosher") and merge them into single canonical ingredients. |
|
||||
| **Thursday** | **Duplicate Multi-Type Items** | Make distinct copies of ingredients where you use multiple varieties or grades of the same item (e.g. *Flour - All Purpose* vs *Flour - Bread High Gluten*). |
|
||||
| **Friday** | **Review Kitchen Database** | Verify that recipes have components, steps, and yields properly structured. |
|
||||
| **Saturday & Sunday** | **Milestone Check** | **Look at that!** You now have a complete, standardized recipe database that can be used actively on the kitchen line. |
|
||||
|
||||
---
|
||||
|
||||
## Week 2: Configure Costs & Purchase Units
|
||||
|
||||
> **Week 2 Goal**: All inventoried ingredients have verified purchase packages, unit costs, and yield factors.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Gather recent supplier invoices, receipts (e.g., Walmart, Sam's Club, US Foods, Sysco), and vendor order guides. |
|
||||
| **Tuesday** | **Invoice Processing & Linking** | Ingest invoice lines into the system to extract package sizes, prices, and vendor SKU codes. |
|
||||
| **Wednesday** | **Manual Costing** | For specialty or local market items without digital invoices, enter package costs manually on the ingredient cost panel. |
|
||||
| **Thursday** | **Spreadsheet Import** | If you maintain vendor price lists in spreadsheets, upload or batch-map purchase packages into your catalog. |
|
||||
| **Friday** | **Map New Purchase Items** | Use the Purchase Items table to map raw invoice line descriptions to their canonical formulation ingredients. |
|
||||
| **Saturday** | **Audit Missing Costs** | Filter your ingredient directory to inspect which items are still unpriced. Add missing package sizes. |
|
||||
| **Sunday** | **Milestone Check** | **Prep recipes now show real costs!** Take a well-deserved break—your recipe costing foundation is complete. |
|
||||
|
||||
---
|
||||
|
||||
## Week 3: Build Location Count Sheets
|
||||
|
||||
> **Week 3 Goal**: Sheet-to-shelf inventory count lists are configured for each physical storage area.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Identify all physical storage areas across your operation (e.g., *Walk-In Cooler*, *Dry Storage*, *Freezer*, *Bar*, *Line Drawers*). |
|
||||
| **Tuesday** | **Create Count Sheets** | Go to **+ New** and select **Count Sheet**. Ensure count sheets are strictly location-specific. *(Note: Only managers can create count templates).* |
|
||||
| **Wednesday** | **Order "Sheet to Shelf"** | Arrange ingredients in the exact physical order they appear on your shelves (top-to-bottom, left-to-right). This maximizes counting speed and prevents missed items. |
|
||||
| **Thursday** | **Add Ingredients & Batches** | Add raw ingredients (green icon) and prepped batch recipes (blue icon) to each count sheet. Drag and drop to reorder. |
|
||||
| **Friday** | **Set Count Units** | Verify and adjust count units (e.g. *Cases*, *Bags*, *Each*, *Pounds*) to match how cooks physically count each shelf. Count units default to the ingredient's primary purchase unit. |
|
||||
| **Saturday & Sunday** | **Milestone Check** | **Almost there!** All location count sheets are structured and ready for validation. |
|
||||
|
||||
---
|
||||
|
||||
## Week 4: Test Run & Validate Inventory Lists
|
||||
|
||||
> **Week 4 Goal**: Perform a dry-run test count to uncover unit mismatch errors, pack size discrepancies, or missing items.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Goal Kickoff** | Schedule a 20-minute test run with key kitchen leads before service. |
|
||||
| **Tuesday** | **Enter Test Count Values** | Enter a dummy quantity of **`1`** in every column (or enter last month's closing count). Save and submit each location sheet individually (do not submit total final count). |
|
||||
| **Wednesday** | **Export Valuation Report** | Review the calculated on-hand values and line-item totals in the analytics review. |
|
||||
| **Thursday** | **Identify Discrepancies** | Look for extended dollar values that look unusually high or low. This highlights where pack sizes (e.g. $50/case counted as 1 ea = $50 vs $2.08) or count units need calibration. |
|
||||
| **Friday** | **Correct Count Templates** | Update pack sizes, count units, or ingredient equivalencies based on test run findings. |
|
||||
| **Saturday & Sunday** | **Milestone Check** | **Take a deep breath!** Your inventory templates are calibrated, validated, and ready for real operational use. |
|
||||
|
||||
---
|
||||
|
||||
## Week 5: Conduct Your First Live Inventory
|
||||
|
||||
> **Week 5 Goal**: Successfully execute full period-end inventory, capture total valuation, and establish your inventory baseline.
|
||||
|
||||
| Day | Action Item | Details & Instructions |
|
||||
|---|---|---|
|
||||
| **Monday** | **Conduct Live Count** | Assign team members to their respective locations with mobile devices or clipboards. |
|
||||
| **Tuesday** | **Add Items On the Fly** | If an unlisted item is discovered on a shelf during the count, add it on the fly. *(Remember to add it to the master count template afterward).* |
|
||||
| **Wednesday** | **Review & Submit Count** | Once all location lists are filled, managers review pending location totals and submit the total inventory count. |
|
||||
| **Thursday** | **Analyze Inventory Valuation** | Review the total dollar valuation report by storage location and ingredient category. *(Calculations finalize within minutes).* |
|
||||
| **Friday** | **Export Accounting Reports** | Export your finalized inventory valuation breakdown categorized by GL accounting codes for bookkeeping. |
|
||||
| **Saturday & Sunday** | **Celebrate Success!** | You now have a repeatable, accurate, high-speed inventory process embedded into your culinary operations! |
|
||||
@@ -0,0 +1,26 @@
|
||||
# Baker's & Standard Percentages
|
||||
|
||||
In baking and commercial food manufacturing, formulas are often structured using **percentages** to ensure recipe scalability and hydration control.
|
||||
|
||||
---
|
||||
|
||||
## 1. Standard Percentage vs. Baker's Percentage
|
||||
|
||||
### Standard % (Total Formulation Basis)
|
||||
$$\text{Standard \%} = \frac{\text{Ingredient Weight}}{\text{Total Batch Weight}} \times 100$$
|
||||
- In Standard Percentage mode, the sum of all ingredient percentages in the recipe equals **100%**.
|
||||
- Useful for confectionery, dressings, beverages, and general culinary batching.
|
||||
|
||||
### Baker's % (Flour / Basis Member Basis)
|
||||
$$\text{Baker's \%} = \frac{\text{Ingredient Weight}}{\text{Total Basis Flour Weight}} \times 100$$
|
||||
- In Baker's Percentage mode, the flour(s) or designated base ingredients are flagged as **Base Members** (`basis_member = true`) and sum to **100%**.
|
||||
- All other ingredients (water/hydration, salt, yeast, sugar, butter) are expressed as a percentage relative to the total flour weight (e.g. 75% hydration water, 2% salt, 1.5% yeast).
|
||||
|
||||
---
|
||||
|
||||
## 2. Dynamic Interactive Percentage Editing
|
||||
|
||||
When editing in the Recipe Structure Editor:
|
||||
1. Enable **Calculate %** and select **Baker's %** or **Standard %**.
|
||||
2. For Baker's %, toggle the **Base** checkbox on the flour/grain ingredients.
|
||||
3. Editing an ingredient's percentage dynamically recalibrates its required physical weight and quantity in grams automatically!
|
||||
@@ -0,0 +1,34 @@
|
||||
# Scaling, Batching & Yield Calculations
|
||||
|
||||
Formulation is a weight-first formulation engine designed to scale recipes accurately across commercial batch sizes without calculation rounding drift.
|
||||
|
||||
---
|
||||
|
||||
## 1. Batch Multipliers vs. Yield-Target Scaling
|
||||
|
||||
Recipes can be scaled in two primary modes:
|
||||
|
||||
### A. Batch Multiplier (`x` factor)
|
||||
- Entering a batch multiplier (e.g. `0.5x`, `2x`, `5x`, `10x`) multiplies every ingredient quantity by the exact factor.
|
||||
- Pre-configured quick buttons allow instant one-tap scaling during active kitchen prep.
|
||||
|
||||
### B. Target Yield Scaling
|
||||
- Specify an exact required total batch yield (e.g., scale a sauce recipe to yield exactly `1,500 grams` or `4.5 quarts`).
|
||||
- Formulation calculates the precise scale factor required to produce that yield based on total recipe weight.
|
||||
|
||||
---
|
||||
|
||||
## 2. Weight-Based Auto-Yield Calculation
|
||||
|
||||
When **Auto-Calculate Total Yield** is toggled ON:
|
||||
1. Formulation calculates the weight in grams for every ingredient in the recipe using canonical unit conversion factors or ingredient-specific density measurements.
|
||||
2. The total recipe yield quantity is automatically updated as the exact sum of all ingredient weights.
|
||||
3. If an ingredient cannot be converted to weight (due to a missing UoM volume-to-weight equivalency), an informative notice displays: *"Auto yield excludes N ingredient amounts without a weight equivalency."*
|
||||
|
||||
---
|
||||
|
||||
## 3. Unit Conversion Safety
|
||||
|
||||
- Ingredients measured in mass (e.g. `g`, `kg`, `oz`, `lb`) convert losslessly across all mass units.
|
||||
- Ingredients measured in volume (e.g. `cup`, `tbsp`, `tsp`, `liter`, `ml`) require an ingredient density measurement (e.g. `1 cup = 120g`) to convert to mass.
|
||||
- Cross-dimensional universal conversions without density data are intentionally rejected to maintain strict culinary accuracy.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Sub-recipes & Prep Methods
|
||||
|
||||
Recipes in Formulation can seamlessly nest other recipes as **sub-recipes**, enabling modular batch preparation and accurate cost/nutrition rollup.
|
||||
|
||||
---
|
||||
|
||||
## 1. Using Sub-recipes in Formulations
|
||||
|
||||
- **Nesting**: When adding an ingredient to a recipe row, you can select an existing Recipe (tagged with the blue Recipe badge) rather than a raw ingredient.
|
||||
- **Cascading Cost & Nutrition**:
|
||||
- The sub-recipe's unit cost and nutritional profile are calculated based on its own ingredients and yield, and cascaded into the parent recipe.
|
||||
- Changes to a base sub-recipe (e.g. *House Mayonnaise*) automatically propagate up to all dishes that include it (e.g. *Aioli*, *Tartar Sauce*, *Sandwich Spread*).
|
||||
|
||||
---
|
||||
|
||||
## 2. Structured Prep Method
|
||||
|
||||
The **Prep Method** editor allows organizing kitchen instructions into numbered, ordered steps:
|
||||
|
||||
- **Numbered Instructions**: Step-by-step prep directions with drag-and-drop reordering handles.
|
||||
- **Section Headers**: Add intermediate headings (e.g. `To Sear:`, `Dry Mix:`, `To Garnish:`) by ending the line with a colon `:`.
|
||||
- **Prep Notes**: Add inline notes (e.g. `(Let rest for 15 minutes before carving)`) by wrapping text in parentheses `(...)`.
|
||||
- **Bulk Prep Import**: Paste entire text documents or recipes into the bulk import modal to automatically parse headings, numbered steps, and notes into structured cards.
|
||||
@@ -0,0 +1,37 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_locations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_counts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
counted_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_count_items (
|
||||
count_id TEXT NOT NULL REFERENCES inventory_counts(id) ON DELETE CASCADE,
|
||||
location_id TEXT REFERENCES inventory_locations(id),
|
||||
ingredient_id TEXT NOT NULL REFERENCES ingredients(id),
|
||||
quantity REAL NOT NULL,
|
||||
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||
unit_cost REAL,
|
||||
extended_cost REAL,
|
||||
PRIMARY KEY (count_id, location_id, ingredient_id)
|
||||
);
|
||||
|
||||
-- Seed baseline standard locations if table is empty
|
||||
INSERT OR IGNORE INTO inventory_locations (id, name, position, deleted_at) VALUES
|
||||
('loc_walk_in', 'Walk-in Cooler', 1, NULL),
|
||||
('loc_dry_storage', 'Dry Storage', 2, NULL),
|
||||
('loc_freezer', 'Freezer', 3, NULL),
|
||||
('loc_bar', 'Bar & Service', 4, NULL),
|
||||
('loc_line', 'Prep Line', 5, NULL);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Soft delete parity for purchase items
|
||||
ALTER TABLE purchase_items ADD COLUMN deleted_at TEXT;
|
||||
@@ -2,30 +2,102 @@
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../components/DetailUtility.astro";
|
||||
import { openDatabase, refreshSiteProjection } from "../../../lib/database";
|
||||
import {
|
||||
openDatabase,
|
||||
permanentlyDeleteArchivedItems,
|
||||
restoreArchivedItems,
|
||||
} from "../../../lib/database";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
import { readOnlyMode } from "../../../lib/runtime";
|
||||
|
||||
const database = openDatabase({ readOnly: false });
|
||||
if (!database) return Astro.redirect("/app/", 303);
|
||||
|
||||
let error = "";
|
||||
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);
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
const intent = String(form.get("intent") ?? "restore");
|
||||
|
||||
if (intent === "restore") {
|
||||
const type = String(form.get("type"));
|
||||
const id = String(form.get("id"));
|
||||
restoreArchivedItems(database, [{ id, type }]);
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
if (intent === "delete") {
|
||||
const type = String(form.get("type"));
|
||||
const id = String(form.get("id"));
|
||||
permanentlyDeleteArchivedItems(database, [{ id, type }]);
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
if (intent === "batch_restore") {
|
||||
const selectedItems = form.getAll("selected_item").map((val) => {
|
||||
const [type, id] = String(val).split(":", 2);
|
||||
return { type, id };
|
||||
});
|
||||
if (selectedItems.length > 0) {
|
||||
restoreArchivedItems(database, selectedItems);
|
||||
}
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
|
||||
if (intent === "batch_delete") {
|
||||
const selectedItems = form.getAll("selected_item").map((val) => {
|
||||
const [type, id] = String(val).split(":", 2);
|
||||
return { type, id };
|
||||
});
|
||||
if (selectedItems.length > 0) {
|
||||
permanentlyDeleteArchivedItems(database, selectedItems);
|
||||
}
|
||||
database.close();
|
||||
return Astro.redirect("/app/archive/", 303);
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Action failed.";
|
||||
}
|
||||
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 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 allItems = [...recipes, ...ingredients, ...books].sort((a, b) => a.name.localeCompare(b.name));
|
||||
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 purchases = (
|
||||
database
|
||||
.prepare(
|
||||
"SELECT id, name, deleted_at FROM purchase_items WHERE deleted_at IS NOT NULL"
|
||||
)
|
||||
.all() as any[]
|
||||
).map((x) => ({ ...x, type: "purchase" as const }));
|
||||
|
||||
const allItems = [...recipes, ...ingredients, ...books, ...purchases].sort(
|
||||
(a, b) => a.name.localeCompare(b.name)
|
||||
);
|
||||
database.close();
|
||||
|
||||
const requestedFilter = Astro.url.searchParams.get("type") ?? "all";
|
||||
@@ -40,9 +112,17 @@ const filteredItems = allItems.filter((item) => {
|
||||
function formatDeleteDate(dateStr: string | null) {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(dateStr.includes("Z") || dateStr.includes("T") ? dateStr : `${dateStr.replace(" ", "T")}Z`);
|
||||
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" });
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
@@ -54,6 +134,8 @@ function formatDeleteDate(dateStr: string | null) {
|
||||
<DetailUtility />
|
||||
|
||||
<main class="archive-workspace">
|
||||
{error && <div class="notice archive-error-banner">{error}</div>}
|
||||
|
||||
<header class="archive-header">
|
||||
<div class="archive-header-left">
|
||||
<nav class="archive-breadcrumbs">
|
||||
@@ -66,7 +148,7 @@ function formatDeleteDate(dateStr: string | null) {
|
||||
</nav>
|
||||
<h1>Archive</h1>
|
||||
<p class="archive-subtitle">
|
||||
Restore recipes, ingredients, and recipe books removed from the active workspace.
|
||||
Restore recipes, ingredients, recipe books, and purchase items or permanently purge them.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
@@ -89,67 +171,134 @@ function formatDeleteDate(dateStr: string | null) {
|
||||
<span>Recipe books</span>
|
||||
<span class="chip-count">{books.length}</span>
|
||||
</a>
|
||||
<a href="/app/archive/?type=purchase" class:list={["archive-chip", { active: requestedFilter === "purchase" }]}>
|
||||
<span>Purchase items</span>
|
||||
<span class="chip-count">{purchases.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>
|
||||
<form method="post" id="archive-batch-form">
|
||||
<input type="hidden" name="intent" id="batch-intent" value="batch_restore" />
|
||||
|
||||
<div class="archive-table">
|
||||
<div class="archive-table-head">
|
||||
{!readOnlyMode && (
|
||||
<span class="head-col select">
|
||||
<input type="checkbox" id="select-all-archive" aria-label="Select all archived items" />
|
||||
</span>
|
||||
)}
|
||||
<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">
|
||||
{!readOnlyMode && (
|
||||
<div class="archive-select-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="selected_item"
|
||||
value={`${item.type}:${item.id}`}
|
||||
class="archive-item-checkbox"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
{item.type === "purchase" && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
|
||||
<path d="M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"/>
|
||||
</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">
|
||||
{!readOnlyMode && (
|
||||
<div class="row-single-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="archive-restore-btn"
|
||||
onclick={`this.form.querySelector('#batch-intent').value='restore'; const hidden = document.createElement('input'); hidden.type='hidden'; hidden.name='type'; hidden.value='${item.type}'; this.form.appendChild(hidden); const hiddenId = document.createElement('input'); hiddenId.type='hidden'; hiddenId.name='id'; hiddenId.value='${item.id}'; this.form.appendChild(hiddenId);`}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
type="submit"
|
||||
class="archive-delete-btn"
|
||||
title="Permanently Delete"
|
||||
onclick={`if(!confirm('Permanently delete ${item.name}? This cannot be undone.')) return false; this.form.querySelector('#batch-intent').value='delete'; const hidden = document.createElement('input'); hidden.type='hidden'; hidden.name='type'; hidden.value='${item.type}'; this.form.appendChild(hidden); const hiddenId = document.createElement('input'); hiddenId.type='hidden'; hiddenId.name='id'; hiddenId.value='${item.id}'; this.form.appendChild(hiddenId);`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
<!-- Sticky Batch Actions Floating Bar -->
|
||||
{!readOnlyMode && (
|
||||
<div id="archive-floating-bar" class="archive-floating-bar hidden">
|
||||
<span id="selected-count-label">0 items selected</span>
|
||||
<div class="floating-bar-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="batch-restore-btn"
|
||||
onclick="document.getElementById('batch-intent').value='batch_restore'"
|
||||
>
|
||||
Restore Selected
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="batch-delete-btn"
|
||||
onclick="if(!confirm('Permanently delete the selected items? Items will be purged from the database.')) return false; document.getElementById('batch-intent').value='batch_delete';"
|
||||
>
|
||||
Permanently Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<div class="archive-empty-state">
|
||||
<div class="empty-icon-circle">
|
||||
@@ -167,3 +316,137 @@ function formatDeleteDate(dateStr: string | null) {
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const selectAll = document.getElementById("select-all-archive");
|
||||
const checkboxes = document.querySelectorAll(".archive-item-checkbox");
|
||||
const floatingBar = document.getElementById("archive-floating-bar");
|
||||
const countLabel = document.getElementById("selected-count-label");
|
||||
|
||||
function syncBatchBar() {
|
||||
const checkedCount = document.querySelectorAll(".archive-item-checkbox:checked").length;
|
||||
if (checkedCount > 0) {
|
||||
floatingBar?.classList.remove("hidden");
|
||||
if (countLabel) countLabel.textContent = `${checkedCount} item${checkedCount === 1 ? "" : "s"} selected`;
|
||||
} else {
|
||||
floatingBar?.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", () => {
|
||||
checkboxes.forEach((cb) => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
syncBatchBar();
|
||||
});
|
||||
}
|
||||
|
||||
checkboxes.forEach((cb) => {
|
||||
cb.addEventListener("change", syncBatchBar);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.archive-error-banner {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-left: 4px solid #ef4444;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.archive-table-head,
|
||||
.archive-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 34px minmax(16rem, 1fr) 10rem 140px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.archive-select-cell,
|
||||
.head-col.select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.row-single-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.archive-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
color: var(--muted);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-delete-btn:hover {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Floating Bar */
|
||||
.archive-floating-bar {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #050841;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 10px 25px rgba(5, 8, 65, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
z-index: 1000;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.archive-floating-bar.hidden {
|
||||
display: none;
|
||||
}
|
||||
.floating-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.batch-restore-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.batch-restore-btn:hover {
|
||||
background: var(--blue-hover);
|
||||
}
|
||||
.batch-delete-btn {
|
||||
padding: 6px 14px;
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.batch-delete-btn:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,40 +2,50 @@
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import EntityDirectory from "../../../components/EntityDirectory";
|
||||
const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase",string> = {
|
||||
const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase"|"inventory",string> = {
|
||||
recipe:"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",
|
||||
ingredient:"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",
|
||||
book:"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",
|
||||
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"
|
||||
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z",
|
||||
inventory:"M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"
|
||||
};
|
||||
const TYPE_ICON_TRANSFORMS:Record<"recipe"|"ingredient"|"book"|"purchase",string|undefined> = {
|
||||
const TYPE_ICON_TRANSFORMS:Record<"recipe"|"ingredient"|"book"|"purchase"|"inventory",string|undefined> = {
|
||||
recipe:"scale(1.1, 1.1) translate(4px, 3.5px)",
|
||||
ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)",
|
||||
book:"scale(1.1, 1.1) translate(3.5px, 2.5px)",
|
||||
purchase:undefined
|
||||
purchase:undefined,
|
||||
inventory:undefined
|
||||
};
|
||||
import { openDatabase } from "../../../lib/database";
|
||||
import { readOnlyMode } from "../../../lib/runtime";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
import type {
|
||||
DirectoryBookRow,
|
||||
DirectoryIngredientRow,
|
||||
DirectoryPurchaseRow,
|
||||
DirectoryRecipeRow,
|
||||
} from "../../../lib/repository";
|
||||
|
||||
const database = openDatabase();
|
||||
if (!database) return new Response("Database unavailable", { status:503 });
|
||||
const recipes=database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id,
|
||||
if (!database) return new Response("Database unavailable", { status: 503 });
|
||||
const recipes = database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id,
|
||||
(SELECT count(*) FROM recipe_items ri WHERE ri.recipe_id=r.id) item_count,
|
||||
(SELECT count(*) FROM recipe_steps rs WHERE rs.recipe_id=r.id AND rs.instruction LIKE 'TODO:%') placeholder_count
|
||||
FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as any[];
|
||||
const ingredients=database.prepare(`SELECT i.id,i.name,i.status,
|
||||
FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as unknown as DirectoryRecipeRow[];
|
||||
const ingredients = database.prepare(`SELECT i.id,i.name,i.status,
|
||||
(SELECT count(*) FROM recipe_items r WHERE r.ingredient_id=i.id) recipe_count,
|
||||
(SELECT count(*) FROM price_observations po JOIN purchase_items p ON p.id=po.purchase_item_id WHERE p.ingredient_id=i.id) price_count,
|
||||
(SELECT count(*) FROM source_mappings m WHERE m.subject_type='ingredient' AND m.subject_id=i.id AND m.mapping_type='nutrition' AND m.status='reviewed') nutrition_count
|
||||
FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as any[];
|
||||
const books=database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as any[];
|
||||
const purchases=database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id,
|
||||
FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as unknown as DirectoryIngredientRow[];
|
||||
const books = database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as unknown as DirectoryBookRow[];
|
||||
const purchases = database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id,
|
||||
(SELECT amount FROM price_observations x WHERE x.purchase_item_id=p.id ORDER BY effective_at DESC LIMIT 1) latest_price
|
||||
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as any[];
|
||||
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as unknown as DirectoryPurchaseRow[];
|
||||
const inventoryCounts = database.prepare("SELECT count(*) as c FROM inventory_counts WHERE deleted_at IS NULL").get() as { c: number } | undefined;
|
||||
database.close();
|
||||
|
||||
const requested=Astro.url.searchParams.get("type");
|
||||
if (requested === "inventory") return Astro.redirect("/app/inventory/", 303);
|
||||
const type=["recipe","ingredient","book","purchase"].includes(requested??"")?requested:undefined;
|
||||
const query=(Astro.url.searchParams.get("q")??"").trim();
|
||||
const normalizedQuery=query.toLocaleLowerCase();
|
||||
@@ -47,11 +57,12 @@ const filtering=attention||missingCost||noUsda||unused||emptyRecipe||placeholder
|
||||
const filteredIngredients=ingredients.filter((ingredient)=>{if(!filtering)return true;const selected=[missingCost&&ingredient.price_count===0,noUsda&&ingredient.nutrition_count===0,unused&&ingredient.recipe_count===0].filter(Boolean);return missingCost||noUsda||unused?selected.length>0:ingredient.price_count===0||ingredient.nutrition_count===0||ingredient.recipe_count===0;});
|
||||
const filteredRecipes=recipes.filter(recipe=>!filtering||(emptyRecipe&&recipe.item_count===0)||(placeholderSteps&&recipe.placeholder_count>0)||(!emptyRecipe&&!placeholderSteps&&(recipe.item_count===0||recipe.placeholder_count>0)));
|
||||
const FILTER_ICON_PATH="M18 6.0201C18 4.81608 17.1873 3.79266 16.0736 3.46156L16.0736 0.722412C16.0736 0.301005 15.7425 -9.86801e-08 15.3211 -1.171e-07C14.8996 -1.35521e-07 14.5685 0.331105 14.5685 0.752512L14.5685 3.49166C13.4548 3.79266 12.6421 4.84618 12.6421 6.0502C12.6421 7.28432 13.4548 8.30774 14.5685 8.63884L14.5685 19.7459C14.5685 20.1673 14.8996 20.4984 15.3211 20.4984C15.7425 20.4984 16.0736 20.1673 16.0736 19.7459L16.0736 8.63884C17.1873 8.27764 18 7.25422 18 6.0201ZM16.495 6.0502C16.495 6.68231 15.9833 7.22412 15.3211 7.22412C14.6588 7.22412 14.1471 6.68231 14.1471 6.0502C14.1471 5.41809 14.6588 4.87628 15.3211 4.87628C15.9833 4.87628 16.495 5.41809 16.495 6.0502Z";
|
||||
const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"}>=[
|
||||
const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"|"inventory";href?:string}>=[
|
||||
{type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"},
|
||||
{type:"ingredient",label:"Ingredients",count:ingredients.length,kind:"ingredient"},
|
||||
{type:"book",label:"Recipe books",count:books.length,kind:"book"},
|
||||
{type:"purchase",label:"Purchase items",count:purchases.length,kind:"purchase"},
|
||||
{type:"inventory",label:"Inventory",count:inventoryCounts?.c ?? 0,kind:"inventory",href:"/app/inventory/"},
|
||||
];
|
||||
const allSearchResults=normalizedQuery ? [
|
||||
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
|
||||
@@ -95,7 +106,7 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in
|
||||
</div>
|
||||
<section class="shell directory-workspace">
|
||||
<nav class="workspace-pills" aria-label="Workspaces">
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={`/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={tab.href ?? `/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
|
||||
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
|
||||
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
|
||||
{!readOnlyMode&&<a class="archive-link" href="/app/archive/">Archive</a>}
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase } from "../../../../lib/database";
|
||||
import {
|
||||
getInventoryCountDetail,
|
||||
saveInventoryCountItems,
|
||||
} from "../../../../lib/repository/inventory-repository";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
|
||||
const id = Astro.params.id!;
|
||||
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 === "complete" || intent === "reopen") {
|
||||
const ingredientIds = form.getAll("ingredient_id").map(String);
|
||||
const locationIds = form.getAll("location_id").map((v) => String(v) || null);
|
||||
const quantities = form.getAll("quantity").map((v) => Number(v) || 0);
|
||||
const unitIds = form.getAll("unit_id").map(String);
|
||||
|
||||
const items = ingredientIds.map((ingId, idx) => ({
|
||||
ingredient_id: ingId,
|
||||
location_id: locationIds[idx] ?? null,
|
||||
quantity: quantities[idx] ?? 0,
|
||||
unit_id: unitIds[idx] ?? "gram",
|
||||
}));
|
||||
|
||||
const newStatus =
|
||||
intent === "complete"
|
||||
? "completed"
|
||||
: intent === "reopen"
|
||||
? "open"
|
||||
: undefined;
|
||||
|
||||
saveInventoryCountItems(database, id, items, newStatus);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/inventory/${id}/`, 303);
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to save count sheet.";
|
||||
}
|
||||
}
|
||||
|
||||
const count = getInventoryCountDetail(database, id);
|
||||
if (!count) {
|
||||
database.close();
|
||||
return new Response("Inventory count session not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch all available units and active ingredients for the quick-add selector
|
||||
const allUnits = database.prepare("SELECT id, name, symbol FROM units ORDER BY name").all() as Array<{ id: string; name: string; symbol: string }>;
|
||||
const activeIngredients = database.prepare("SELECT id, name FROM ingredients WHERE status='active' AND deleted_at IS NULL ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
|
||||
database.close();
|
||||
|
||||
const selectedLocation = Astro.url.searchParams.get("loc") ?? "all";
|
||||
const filteredItems = count.items.filter((item) => {
|
||||
if (selectedLocation === "all") return true;
|
||||
if (selectedLocation === "unassigned") return !item.location_id;
|
||||
return item.location_id === selectedLocation;
|
||||
});
|
||||
|
||||
const isCompleted = count.status === "completed";
|
||||
---
|
||||
|
||||
<BaseLayout title={count.title} immersive>
|
||||
<div class="count-sheet-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="count-sheet-workspace">
|
||||
{error && <div class="notice count-notice">{error}</div>}
|
||||
|
||||
<header class="count-sheet-header">
|
||||
<div class="count-sheet-header-left">
|
||||
<nav class="count-breadcrumbs">
|
||||
<a href="/app/inventory/">
|
||||
<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>Inventory counts</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="count-title-row">
|
||||
<h1>{count.title}</h1>
|
||||
<span class={`status-badge ${count.status}`}>
|
||||
{isCompleted ? "Completed" : "Open Draft"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="count-sheet-meta">
|
||||
<span>Count Date: <strong>{count.counted_at}</strong></span>
|
||||
{count.notes && <span>· {count.notes}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="count-sheet-header-right">
|
||||
<div class="total-valuation-pill">
|
||||
<span class="val-label">Total On-Hand Valuation</span>
|
||||
<strong class="val-amount font-mono" id="header-total-value">
|
||||
${count.total_value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div class="count-header-actions">
|
||||
{isCompleted ? (
|
||||
<form method="post" class="inline-action-form">
|
||||
<input type="hidden" name="intent" value="reopen" />
|
||||
<button type="submit" class="secondary-btn">Reopen Count</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" class="secondary-btn" id="save-draft-trigger">
|
||||
Save Draft
|
||||
</button>
|
||||
<button type="button" class="complete-btn" id="complete-count-trigger">
|
||||
✓ Complete Count
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Location Filter Tabs -->
|
||||
<nav class="location-tabs" aria-label="Filter count sheet by location">
|
||||
<a
|
||||
href={`/app/inventory/${id}/`}
|
||||
class:list={["loc-tab", { active: selectedLocation === "all" }]}
|
||||
>
|
||||
<span>All Locations</span>
|
||||
<small>{count.items.length}</small>
|
||||
</a>
|
||||
{count.locations.map((loc) => {
|
||||
const locItemCount = count.items.filter((i) => i.location_id === loc.id).length;
|
||||
return (
|
||||
<a
|
||||
href={`/app/inventory/${id}/?loc=${loc.id}`}
|
||||
class:list={["loc-tab", { active: selectedLocation === loc.id }]}
|
||||
>
|
||||
<span>{loc.name}</span>
|
||||
<small>{locItemCount}</small>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<!-- Count Sheet Table Form -->
|
||||
<form method="post" id="count-sheet-form">
|
||||
<input type="hidden" name="intent" id="form-intent" value="save" />
|
||||
|
||||
<div class="count-table-container">
|
||||
<table class="count-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ing">Ingredient</th>
|
||||
<th class="col-loc">Location</th>
|
||||
<th class="col-qty">Quantity</th>
|
||||
<th class="col-unit">Unit</th>
|
||||
<th class="col-cost">Unit Cost</th>
|
||||
<th class="col-ext">Extended Value</th>
|
||||
{!isCompleted && !readOnlyMode && <th class="col-del"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="count-table-body">
|
||||
{filteredItems.map((item) => (
|
||||
<tr class="count-item-row" data-unit-cost={item.unit_cost ?? 0}>
|
||||
<td class="col-ing">
|
||||
<input type="hidden" name="ingredient_id" value={item.ingredient_id} />
|
||||
<strong>{item.ingredient_name}</strong>
|
||||
</td>
|
||||
<td class="col-loc">
|
||||
{isCompleted || readOnlyMode ? (
|
||||
<span>{item.location_name ?? "Unassigned"}</span>
|
||||
) : (
|
||||
<select name="location_id" class="table-loc-select">
|
||||
<option value="">(Unassigned)</option>
|
||||
{count.locations.map((loc) => (
|
||||
<option value={loc.id} selected={loc.id === item.location_id}>
|
||||
{loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-qty">
|
||||
{isCompleted || readOnlyMode ? (
|
||||
<span class="font-mono">{item.quantity}</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
min="0"
|
||||
step="any"
|
||||
value={item.quantity}
|
||||
class="qty-input font-mono"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-unit">
|
||||
{isCompleted || readOnlyMode ? (
|
||||
<span>{item.unit_symbol}</span>
|
||||
) : (
|
||||
<select name="unit_id" class="table-unit-select">
|
||||
{allUnits.map((u) => (
|
||||
<option value={u.id} selected={u.id === item.unit_id}>
|
||||
{u.symbol || u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-cost font-mono">
|
||||
{item.unit_cost != null ? (
|
||||
`$${item.unit_cost.toFixed(4)}`
|
||||
) : (
|
||||
<span class="unpriced">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="col-ext font-mono row-extended-value">
|
||||
${(item.extended_cost ?? 0).toFixed(2)}
|
||||
</td>
|
||||
{!isCompleted && !readOnlyMode && (
|
||||
<td class="col-del">
|
||||
<button
|
||||
type="button"
|
||||
class="row-delete-btn"
|
||||
title="Remove from count"
|
||||
onclick="this.closest('tr').remove(); updateLiveTotals();"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div class="empty-location-notice">
|
||||
<p>No items assigned to this storage location in this count session.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCompleted && !readOnlyMode && (
|
||||
<div class="add-item-bar">
|
||||
<select id="quick-add-select" class="quick-add-dropdown">
|
||||
<option value="">+ Add item to count sheet…</option>
|
||||
{activeIngredients
|
||||
.filter((ing) => !count.items.some((i) => i.ingredient_id === ing.id))
|
||||
.map((ing) => (
|
||||
<option value={ing.id}>{ing.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" id="quick-add-btn" class="quick-add-btn">
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script is:inline>
|
||||
function updateLiveTotals() {
|
||||
let total = 0;
|
||||
document.querySelectorAll(".count-item-row").forEach((row) => {
|
||||
const unitCost = parseFloat(row.getAttribute("data-unit-cost") || "0");
|
||||
const qtyInput = row.querySelector(".qty-input");
|
||||
const qty = qtyInput ? parseFloat(qtyInput.value || "0") : 0;
|
||||
const ext = unitCost * qty;
|
||||
const extEl = row.querySelector(".row-extended-value");
|
||||
if (extEl) extEl.textContent = `$${ext.toFixed(2)}`;
|
||||
total += ext;
|
||||
});
|
||||
const headerEl = document.getElementById("header-total-value");
|
||||
if (headerEl) headerEl.textContent = `$${total.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll(".qty-input").forEach((input) => {
|
||||
input.addEventListener("input", updateLiveTotals);
|
||||
});
|
||||
|
||||
const saveTrigger = document.getElementById("save-draft-trigger");
|
||||
if (saveTrigger) {
|
||||
saveTrigger.addEventListener("click", () => {
|
||||
const form = document.getElementById("count-sheet-form");
|
||||
const intent = document.getElementById("form-intent");
|
||||
if (form && intent) {
|
||||
intent.value = "save";
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const completeTrigger = document.getElementById("complete-count-trigger");
|
||||
if (completeTrigger) {
|
||||
completeTrigger.addEventListener("click", () => {
|
||||
if (confirm("Complete and finalize this inventory count? On-hand valuations will be finalized.")) {
|
||||
const form = document.getElementById("count-sheet-form");
|
||||
const intent = document.getElementById("form-intent");
|
||||
if (form && intent) {
|
||||
intent.value = "complete";
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const addBtn = document.getElementById("quick-add-btn");
|
||||
const selectEl = document.getElementById("quick-add-select");
|
||||
if (addBtn && selectEl) {
|
||||
addBtn.addEventListener("click", () => {
|
||||
const selectedId = selectEl.value;
|
||||
const selectedName = selectEl.options[selectEl.selectedIndex]?.text;
|
||||
if (!selectedId) return;
|
||||
|
||||
const tbody = document.getElementById("count-table-body");
|
||||
if (tbody) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "count-item-row";
|
||||
tr.setAttribute("data-unit-cost", "0");
|
||||
tr.innerHTML = `
|
||||
<td class="col-ing">
|
||||
<input type="hidden" name="ingredient_id" value="${selectedId}" />
|
||||
<strong>${selectedName}</strong>
|
||||
</td>
|
||||
<td class="col-loc">
|
||||
<select name="location_id" class="table-loc-select">
|
||||
<option value="">(Unassigned)</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-qty">
|
||||
<input type="number" name="quantity" min="0" step="any" value="0" class="qty-input font-mono" required />
|
||||
</td>
|
||||
<td class="col-unit">
|
||||
<select name="unit_id" class="table-unit-select">
|
||||
<option value="gram">g</option>
|
||||
<option value="each">ea</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-cost font-mono">—</td>
|
||||
<td class="col-ext font-mono row-extended-value">$0.00</td>
|
||||
<td class="col-del">
|
||||
<button type="button" class="row-delete-btn" onclick="this.closest('tr').remove(); updateLiveTotals();">×</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
tr.querySelector(".qty-input")?.addEventListener("input", updateLiveTotals);
|
||||
selectEl.remove(selectEl.selectedIndex);
|
||||
selectEl.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.count-sheet-workspace {
|
||||
padding: 72px max(24px, calc((100vw - 1160px) / 2)) 80px;
|
||||
}
|
||||
.count-sheet-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.count-breadcrumbs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.count-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.count-title-row h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
}
|
||||
.count-sheet-meta {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.count-sheet-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.total-valuation-pill {
|
||||
background: #f1f5fe;
|
||||
border: 1px solid #dbe4ff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.val-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.val-amount {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--blue);
|
||||
}
|
||||
.count-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.secondary-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #d0d6e4;
|
||||
background: white;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #050841;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.secondary-btn:hover {
|
||||
border-color: #050841;
|
||||
background: #f8faff;
|
||||
}
|
||||
.complete-btn {
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.complete-btn:hover {
|
||||
background: #2b4be0;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.status-badge.open {
|
||||
background: #fff8e6;
|
||||
color: #b25e00;
|
||||
border: 1px solid #ffd599;
|
||||
}
|
||||
.status-badge.completed {
|
||||
background: #e6f9f3;
|
||||
color: #0d8262;
|
||||
border: 1px solid #a3ebd4;
|
||||
}
|
||||
|
||||
/* Location tabs */
|
||||
.location-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.loc-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.loc-tab.active {
|
||||
background: #050841;
|
||||
color: white;
|
||||
border-color: #050841;
|
||||
}
|
||||
.loc-tab small {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.loc-tab.active small {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.count-table-container {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.count-items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.count-items-table th,
|
||||
.count-items-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
.count-items-table th {
|
||||
background: #fbfbfb;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.count-items-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.col-ing {
|
||||
width: 30%;
|
||||
}
|
||||
.col-loc {
|
||||
width: 20%;
|
||||
}
|
||||
.col-qty {
|
||||
width: 15%;
|
||||
}
|
||||
.col-unit {
|
||||
width: 12%;
|
||||
}
|
||||
.col-cost {
|
||||
width: 11%;
|
||||
text-align: right;
|
||||
}
|
||||
.col-ext {
|
||||
width: 12%;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
}
|
||||
.col-del {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.qty-input {
|
||||
width: 90px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.table-loc-select,
|
||||
.table-unit-select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.row-delete-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-delete-btn:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
.unpriced {
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-location-notice {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Add item bar */
|
||||
.add-item-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.quick-add-dropdown {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: white;
|
||||
}
|
||||
.quick-add-btn {
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
background: white;
|
||||
border: 1px solid #050841;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #050841;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.quick-add-btn:hover {
|
||||
color: #3d5df6;
|
||||
border-color: #3d5df6;
|
||||
background: #f1f5fe;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.count-sheet-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.count-sheet-header-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,519 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { openDatabase } from "../../../../lib/database";
|
||||
import {
|
||||
createInventoryCount,
|
||||
getInventoryCounts,
|
||||
getInventoryLocations,
|
||||
} from "../../../../lib/repository/inventory-repository";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
|
||||
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") ?? "create");
|
||||
|
||||
if (intent === "create") {
|
||||
const title = String(form.get("title") ?? "").trim();
|
||||
const counted_at = String(form.get("counted_at") ?? "").trim();
|
||||
const notes = String(form.get("notes") ?? "").trim();
|
||||
const prepopulate = form.get("prepopulate") === "1";
|
||||
|
||||
if (!title) throw new Error("Title is required.");
|
||||
if (!counted_at) throw new Error("Count date is required.");
|
||||
|
||||
const createdId = createInventoryCount(database, {
|
||||
title,
|
||||
counted_at,
|
||||
notes,
|
||||
prepopulate,
|
||||
});
|
||||
|
||||
database.close();
|
||||
return Astro.redirect(`/app/inventory/${createdId}/`, 303);
|
||||
}
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "Unable to create inventory count.";
|
||||
}
|
||||
}
|
||||
|
||||
const counts = getInventoryCounts(database);
|
||||
const locations = getInventoryLocations(database);
|
||||
const totalInventoryValue = counts
|
||||
.filter((c) => c.status === "completed")
|
||||
.reduce((sum, c) => sum + c.total_value, 0);
|
||||
|
||||
database.close();
|
||||
---
|
||||
|
||||
<BaseLayout title="Inventory" immersive>
|
||||
<div class="inventory-page">
|
||||
<DetailUtility />
|
||||
|
||||
<main class="inventory-workspace">
|
||||
{error && <div class="notice inventory-notice">{error}</div>}
|
||||
|
||||
<header class="inventory-header">
|
||||
<div class="inventory-header-left">
|
||||
<nav class="inventory-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>Inventory Count Sessions</h1>
|
||||
<p class="inventory-subtitle">
|
||||
Sheet-to-shelf on-hand stock counts, valuations, and storage locations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div class="inventory-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="primary-count-btn"
|
||||
onclick="document.getElementById('new-count-dialog').showModal()"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
<span>New Count Session</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div class="inventory-summary-cards">
|
||||
<article class="inv-stat-card">
|
||||
<span class="inv-stat-label">Total Count Sessions</span>
|
||||
<strong class="inv-stat-value">{counts.length}</strong>
|
||||
</article>
|
||||
<article class="inv-stat-card">
|
||||
<span class="inv-stat-label">Storage Locations</span>
|
||||
<strong class="inv-stat-value">{locations.length}</strong>
|
||||
</article>
|
||||
<article class="inv-stat-card highlight">
|
||||
<span class="inv-stat-label">Total Completed Valuation</span>
|
||||
<strong class="inv-stat-value">
|
||||
${totalInventoryValue.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{counts.length > 0 ? (
|
||||
<div class="inventory-table">
|
||||
<div class="inventory-table-head">
|
||||
<span class="head-col date">Count Date</span>
|
||||
<span class="head-col title">Session Title</span>
|
||||
<span class="head-col status">Status</span>
|
||||
<span class="head-col items">Items Counted</span>
|
||||
<span class="head-col value">Total Value</span>
|
||||
<span class="head-col actions"></span>
|
||||
</div>
|
||||
|
||||
<div class="inventory-table-body">
|
||||
{counts.map((count) => (
|
||||
<a href={`/app/inventory/${count.id}/`} class="inventory-table-row">
|
||||
<span class="col date">{count.counted_at}</span>
|
||||
<span class="col title">
|
||||
<strong>{count.title}</strong>
|
||||
{count.notes && <small>{count.notes}</small>}
|
||||
</span>
|
||||
<span class="col status">
|
||||
<span class={`status-badge ${count.status}`}>
|
||||
{count.status === "completed" ? "Completed" : "Open Draft"}
|
||||
</span>
|
||||
</span>
|
||||
<span class="col items">{count.item_count} items</span>
|
||||
<span class="col value font-mono">
|
||||
${count.total_value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span class="col actions">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty-inventory-state">
|
||||
<div class="empty-inv-icon">
|
||||
<svg viewBox="0 0 24 24" width="36" height="36" fill="currentColor">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>No inventory counts recorded yet</h2>
|
||||
<p>Create your first location-specific count session to begin tracking on-hand stock and valuations.</p>
|
||||
{!readOnlyMode && (
|
||||
<button
|
||||
type="button"
|
||||
class="primary-count-btn"
|
||||
onclick="document.getElementById('new-count-dialog').showModal()"
|
||||
>
|
||||
Start First Count Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- New Count Session Modal Dialog -->
|
||||
<dialog id="new-count-dialog" class="count-modal-dialog">
|
||||
<form method="post">
|
||||
<input type="hidden" name="intent" value="create" />
|
||||
<div class="dialog-header">
|
||||
<h2>New Inventory Count Session</h2>
|
||||
<button type="button" class="close-btn" onclick="this.closest('dialog').close()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<label class="form-field">
|
||||
<span>Session Title *</span>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
required
|
||||
placeholder="e.g. Month-End Count - August 2026"
|
||||
value={`Count - ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>Count Date *</span>
|
||||
<input
|
||||
type="date"
|
||||
name="counted_at"
|
||||
required
|
||||
value={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-field">
|
||||
<span>Notes / Shift (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
name="notes"
|
||||
placeholder="e.g. Sunday night post-service count"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" name="prepopulate" value="1" checked />
|
||||
<span>Pre-populate all active catalog ingredients with 0 count</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="cancel-btn" onclick="this.closest('dialog').close()">Cancel</button>
|
||||
<button type="submit" class="submit-btn">Create Count Sheet</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</main>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.inventory-workspace {
|
||||
padding: 72px max(24px, calc((100vw - 1160px) / 2)) 80px;
|
||||
}
|
||||
.inventory-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.inventory-breadcrumbs a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.inventory-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.inventory-subtitle {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.primary-count-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.primary-count-btn:hover {
|
||||
background: #2b4be0;
|
||||
}
|
||||
.inventory-summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.inv-stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.inv-stat-card.highlight {
|
||||
background: #f8faff;
|
||||
border-color: #dbe4ff;
|
||||
}
|
||||
.inv-stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.inv-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
.inventory-table {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inventory-table-head,
|
||||
.inventory-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px minmax(180px, 1fr) 130px 140px 140px 40px;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.inventory-table-head {
|
||||
background: #fbfbfb;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.inventory-table-row {
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.inventory-table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.inventory-table-row:hover {
|
||||
background: #f8faff;
|
||||
}
|
||||
.inventory-table-row .title strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
.inventory-table-row .title small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.status-badge.open {
|
||||
background: #fff8e6;
|
||||
color: #b25e00;
|
||||
border: 1px solid #ffd599;
|
||||
}
|
||||
.status-badge.completed {
|
||||
background: #e6f9f3;
|
||||
color: #0d8262;
|
||||
border: 1px solid #a3ebd4;
|
||||
}
|
||||
.col.value {
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
.col.actions {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.empty-inventory-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.empty-inv-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 16px;
|
||||
background: #f1f5fe;
|
||||
color: var(--blue);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.empty-inventory-state h2 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.empty-inventory-state p {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
max-width: 420px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.count-modal-dialog {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
width: min(500px, calc(100vw - 32px));
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.count-modal-dialog::backdrop {
|
||||
background: rgba(5, 8, 65, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.dialog-header h2 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.close-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-field input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dialog-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #fbfbfb;
|
||||
}
|
||||
.cancel-btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #8b93a7;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
.cancel-btn:hover {
|
||||
color: #050841;
|
||||
}
|
||||
.submit-btn {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
background: #3d5df6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.submit-btn:hover {
|
||||
background: #2b4be0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inventory-summary-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.inventory-table-head {
|
||||
display: none;
|
||||
}
|
||||
.inventory-table-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.inventory-table-row .col.items,
|
||||
.inventory-table-row .col.date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,20 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { databaseProjection, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database";
|
||||
import { recipeStructure } from "../../../../lib/database";
|
||||
import {
|
||||
editableRecipe,
|
||||
openDatabase,
|
||||
refreshSiteProjection,
|
||||
saveRecipeMetadata,
|
||||
duplicateRecipe,
|
||||
recipeStructure,
|
||||
} from "../../../../lib/database";
|
||||
import { getRecipeCalculationContext } from "../../../../lib/repository";
|
||||
import RecipeStructureEditor from "../../../../components/RecipeStructureEditor";
|
||||
import RecipeCalculator, { LiveCostValues, LiveNutritionValues } from "../../../../components/RecipeCalculator";
|
||||
import { calculateNutrition } from "../../../../lib/nutrition";
|
||||
import { calculateCost } from "../../../../lib/costing";
|
||||
import type { Ingredient, PrepAction, PurchaseItem, Recipe, SourceMapping, Unit } from "../../../../lib/types";
|
||||
import type { Ingredient, Recipe } from "../../../../lib/types";
|
||||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||||
import { convertWithIngredientMeasures } from "../../../../lib/measurement";
|
||||
import { readOnlyMode } from "../../../../lib/runtime";
|
||||
@@ -30,11 +37,9 @@ if (Astro.request.method === "POST") {
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
|
||||
}
|
||||
if (form.get("intent") === "duplicate") {
|
||||
try {
|
||||
return Astro.redirect(`/app/recipes/${duplicateRecipe(database, id)}/?edit=1`, 303);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
const duplicatedId = duplicateRecipe(database, id);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${duplicatedId}/?edit=1`, 303);
|
||||
}
|
||||
if(form.get("intent")==="auto_yield"){
|
||||
const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found.");
|
||||
@@ -45,34 +50,53 @@ if (Astro.request.method === "POST") {
|
||||
if(original?.quantity>0&&original?.unit_id)saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:original.quantity,yield_unit_id:original.unit_id,yield_servings:current.yield_servings,yield_basis:original.basis??null});
|
||||
delete source.auto_yield_original;database.prepare("UPDATE recipes SET auto_yield=0,source_json=? WHERE id=?").run(JSON.stringify(source),id);refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1`,303);
|
||||
}
|
||||
const projectionNow=databaseProjection(database) as {recipes:Recipe[];ingredients:Ingredient[];units:Unit[];sourceMappings:SourceMapping[]};
|
||||
const recipesNow=new Map(projectionNow.recipes.map(value=>[value.id,value])),ingredientsNow=new Map(projectionNow.ingredients.map(value=>[value.id,value])),unitsNow=new Map(projectionNow.units.map(value=>[value.id,value]));
|
||||
const autoRecipe=recipesNow.get(id)!;let calculatedYieldWeightG=0;const conversionFailures:string[]=[];
|
||||
for(const item of autoRecipe.components.flatMap(component=>component.items).filter(item=>!item.optional)){
|
||||
try{if("ingredient_id" in item.reference){const ingredient=ingredientsNow.get(item.reference.ingredient_id);if(!ingredient)throw new Error("ingredient not found");calculatedYieldWeightG+=convertWithIngredientMeasures(item.amount,"gram",ingredient,unitsNow).quantity;}else{const child=recipesNow.get(item.reference.recipe_id);if(!child)throw new Error("sub-recipe not found");calculatedYieldWeightG+=convertWithIngredientMeasures(item.amount,"gram",{schema_version:2,id:child.id,name:child.title,status:"active",categories:[],measure_conversions:child.measure_conversions},unitsNow).quantity;}}catch{conversionFailures.push("ingredient_id" in item.reference?ingredientsNow.get(item.reference.ingredient_id)?.name??item.reference.ingredient_id:recipesNow.get(item.reference.recipe_id)?.title??item.reference.recipe_id);}
|
||||
const calcContextNow = getRecipeCalculationContext(database, id);
|
||||
if (!calcContextNow) throw new Error("Recipe not found.");
|
||||
const { recipes: recipesNow, ingredients: ingredientsNow, units: unitsNow, domainRecipe: autoRecipe } = calcContextNow;
|
||||
let calculatedYieldWeightG = 0;
|
||||
const conversionFailures: string[] = [];
|
||||
for (const item of autoRecipe.components.flatMap(component => component.items).filter(item => !item.optional)) {
|
||||
try {
|
||||
if ("ingredient_id" in item.reference) {
|
||||
const ingredient = ingredientsNow.get(item.reference.ingredient_id);
|
||||
if (!ingredient) throw new Error("ingredient not found");
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", ingredient, unitsNow).quantity;
|
||||
} else {
|
||||
const child = recipesNow.get(item.reference.recipe_id);
|
||||
if (!child) throw new Error("sub-recipe not found");
|
||||
calculatedYieldWeightG += convertWithIngredientMeasures(item.amount, "gram", { schema_version: 2, id: child.id, name: child.title, status: "active", categories: [], measure_conversions: child.measure_conversions }, unitsNow).quantity;
|
||||
}
|
||||
} catch {
|
||||
conversionFailures.push("ingredient_id" in item.reference ? ingredientsNow.get(item.reference.ingredient_id)?.name ?? item.reference.ingredient_id : recipesNow.get(item.reference.recipe_id)?.title ?? item.reference.recipe_id);
|
||||
}
|
||||
}
|
||||
if(conversionFailures.length)throw new Error(`Auto calculate total yield needs a weight equivalency for: ${conversionFailures.join(", ")}.`);
|
||||
if(!(calculatedYieldWeightG>0))throw new Error("No convertible ingredient weights are available for automatic yield.");
|
||||
source.auto_yield_original={quantity:current.yield_quantity,unit_id:current.yield_unit_id,basis:current.yield_basis};
|
||||
saveRecipeMetadata(database,id,current.save_version,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:calculatedYieldWeightG,yield_unit_id:"gram",yield_servings:current.yield_servings,yield_basis:"theoretical"});
|
||||
database.prepare("UPDATE recipes SET auto_yield=1,source_json=? WHERE id=?").run(JSON.stringify(source),id);refreshSiteProjection(database);
|
||||
database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1#nutrition`,303);
|
||||
if (conversionFailures.length) throw new Error(`Auto calculate total yield needs a weight equivalency for: ${conversionFailures.join(", ")}.`);
|
||||
if (!(calculatedYieldWeightG > 0)) throw new Error("No convertible ingredient weights are available for automatic yield.");
|
||||
source.auto_yield_original = { quantity: current.yield_quantity, unit_id: current.yield_unit_id, basis: current.yield_basis };
|
||||
saveRecipeMetadata(database, id, current.save_version, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: calculatedYieldWeightG, yield_unit_id: "gram", yield_servings: current.yield_servings, yield_basis: "theoretical" });
|
||||
database.prepare("UPDATE recipes SET auto_yield=1,source_json=? WHERE id=?").run(JSON.stringify(source), id);
|
||||
refreshSiteProjection(database);
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1&saved=1#nutrition`, 303);
|
||||
}
|
||||
if (form.get("intent") === "nutrition_servings") {
|
||||
const current=editableRecipe(database,id);if(!current)throw new Error("Recipe not found.");
|
||||
const yieldServings=Number(form.get("yield_servings"));
|
||||
if(!Number.isFinite(yieldServings)||yieldServings<=0)throw new Error("Servings must be greater than zero.");
|
||||
const expectedVersion=Number(form.get("save_version"));
|
||||
saveRecipeMetadata(database,id,expectedVersion,{title:current.title,summary:current.summary,categories_json:current.categories_json,tags_json:current.tags_json,yield_quantity:current.yield_quantity,yield_unit_id:current.yield_unit_id,yield_servings:yieldServings,yield_basis:current.yield_basis});
|
||||
database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`,303);
|
||||
const current = editableRecipe(database, id);
|
||||
if (!current) throw new Error("Recipe not found.");
|
||||
const yieldServings = Number(form.get("yield_servings"));
|
||||
if (!Number.isFinite(yieldServings) || yieldServings <= 0) throw new Error("Servings must be greater than zero.");
|
||||
const expectedVersion = Number(form.get("save_version"));
|
||||
saveRecipeMetadata(database, id, expectedVersion, { title: current.title, summary: current.summary, categories_json: current.categories_json, tags_json: current.tags_json, yield_quantity: current.yield_quantity, yield_unit_id: current.yield_unit_id, yield_servings: yieldServings, yield_basis: current.yield_basis });
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1#nutrition`, 303);
|
||||
}
|
||||
if (form.get("intent") === "conversion") {
|
||||
const fromQuantity=Number(form.get("from_quantity")),toQuantity=Number(form.get("to_quantity"));
|
||||
const fromUnitId=String(form.get("from_unit_id")??""),toUnitId=String(form.get("to_unit_id")??"");
|
||||
if(!(fromQuantity>0&&toQuantity>0)) throw new Error("Equivalency quantities must be positive.");
|
||||
if(!database.prepare("SELECT 1 FROM units WHERE id IN (?,?) HAVING count(*)=2").get(fromUnitId,toUnitId)) throw new Error("Unknown equivalency unit.");
|
||||
database.prepare("INSERT INTO recipe_measure_conversions VALUES (?,?,?,?,?,?,?,?)").run(id,`manual_${Date.now()}`,fromQuantity,fromUnitId,toQuantity,toUnitId,String(form.get("notes")??"").trim()||null,JSON.stringify({source_type:"manual",title:"Recipe application",reviewed:true}));
|
||||
database.close(); return Astro.redirect(`/app/recipes/${id}/?edit=1#equivalencies`,303);
|
||||
const fromQuantity = Number(form.get("from_quantity")), toQuantity = Number(form.get("to_quantity"));
|
||||
const fromUnitId = String(form.get("from_unit_id") ?? ""), toUnitId = String(form.get("to_unit_id") ?? "");
|
||||
if (!(fromQuantity > 0 && toQuantity > 0)) throw new Error("Equivalency quantities must be positive.");
|
||||
if (!database.prepare("SELECT 1 FROM units WHERE id IN (?,?) HAVING count(*)=2").get(fromUnitId, toUnitId)) throw new Error("Unknown equivalency unit.");
|
||||
database.prepare("INSERT INTO recipe_measure_conversions VALUES (?,?,?,?,?,?,?,?)").run(id, `manual_${Date.now()}`, fromQuantity, fromUnitId, toQuantity, toUnitId, String(form.get("notes") ?? "").trim() || null, JSON.stringify({ source_type: "manual", title: "Recipe application", reviewed: true }));
|
||||
database.close();
|
||||
return Astro.redirect(`/app/recipes/${id}/?edit=1#equivalencies`, 303);
|
||||
}
|
||||
const title = String(form.get("title") ?? "").trim();
|
||||
const yieldQuantity = Number(form.get("yield_quantity"));
|
||||
@@ -105,29 +129,29 @@ const recipe = editableRecipe(database, id);
|
||||
if (!recipe) { database.close(); return new Response("Recipe not found", { status: 404 }); }
|
||||
const units = database.prepare("SELECT id, name, symbol, dimension FROM units ORDER BY dimension, name").all() as Array<{ id: string; name: string; symbol: string; dimension: string }>;
|
||||
const structure = recipeStructure(database, id)!;
|
||||
const autoYield=Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as {auto_yield:number}).auto_yield);
|
||||
const autoYield = Boolean((database.prepare("SELECT auto_yield FROM recipes WHERE id=?").get(id) as { auto_yield: number }).auto_yield);
|
||||
const ingredientOptions = (database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>).map((ingredient) => ({
|
||||
...ingredient,
|
||||
aliases: (database.prepare("SELECT name FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name").all(ingredient.id) as Array<{name:string}>).map((entry) => entry.name),
|
||||
aliases: (database.prepare("SELECT name FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name").all(ingredient.id) as Array<{ name: string }>).map((entry) => entry.name),
|
||||
}));
|
||||
const recipeOptions = database.prepare("SELECT id, title AS name FROM recipes WHERE deleted_at IS NULL ORDER BY title").all() as Array<{ id: string; name: string }>;
|
||||
const prepActionOptions = database.prepare("SELECT id, name FROM prep_actions ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
const recipeConversions=database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[];
|
||||
const additional=database.prepare("SELECT station,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
|
||||
const additionalSource=JSON.parse(additional.source_json??"{}"),shelfLife=additionalSource.shelf_life;
|
||||
const projection = databaseProjection(database) as { recipes:Recipe[]; ingredients:Ingredient[]; units:Unit[]; sourceMappings:SourceMapping[]; purchaseItems:PurchaseItem[]; prepActions:PrepAction[] };
|
||||
const domainRecipe = projection.recipes.find((entry) => entry.id === id)!;
|
||||
const recipeMap = new Map(projection.recipes.map((entry) => [entry.id, entry]));
|
||||
const ingredientMap = new Map(projection.ingredients.map((entry) => [entry.id, entry]));
|
||||
const unitMap = new Map(projection.units.map((entry) => [entry.id, entry]));
|
||||
const nutrition = calculateNutrition(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, mappings:new Map(projection.sourceMappings.map((entry) => [entry.id, entry])) });
|
||||
const cost = calculateCost(domainRecipe, { recipes:recipeMap, ingredients:ingredientMap, units:unitMap, purchaseItems:new Map(projection.purchaseItems.map((entry) => [entry.id, entry])), prepActions:new Map(projection.prepActions.map((entry) => [entry.id, entry])) });
|
||||
const reviewedNutritionMappingIds=new Set(projection.sourceMappings.filter((mapping)=>mapping.mapping_type==="nutrition"&&mapping.status==="reviewed"&&Object.keys(mapping.nutrition_per_100g??{}).length>0).map((mapping)=>mapping.id));
|
||||
const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items:component.items.map((item) => { const ingredient="ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child="recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; const mapped=ingredient?(ingredient.nutrition_mapping_ids??[]).some((mappingId)=>reviewedNutritionMappingIds.has(mappingId)):true; return { ...item, basisMember:item.basis_member, label:ingredient?.name ?? child?.title ?? "Unknown", href:ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, attention:Boolean(ingredient&&!mapped),attentionMessage:ingredient&&!mapped?"Nutrition mapping needed":undefined, measureConversions:ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) }));
|
||||
const percentSubjectEntries=domainRecipe.components.flatMap(component=>component.items).map(item=>{const ingredient="ingredient_id" in item.reference?ingredientMap.get(item.reference.ingredient_id):undefined,child="recipe_id" in item.reference?recipeMap.get(item.reference.recipe_id):undefined;return ingredient?[`ingredient:${ingredient.id}`,{key:`ingredient:${ingredient.id}`,value:ingredient}] as [string,{key:string,value:Ingredient|Recipe}]:[`recipe:${child?.id}`,{key:`recipe:${child?.id}`,value:child!}] as [string,{key:string,value:Ingredient|Recipe}];});
|
||||
const percentSubjects=[...new Map<string,{key:string,value:Ingredient|Recipe}>(percentSubjectEntries).values()];
|
||||
const weightRates=Object.fromEntries(percentSubjects.flatMap(subject=>projection.units.map(unit=>{try{return [`${subject.key}:${unit.id}`,convertWithIngredientMeasures({quantity:1,unit_id:unit.id},"gram",subject.value as Ingredient,unitMap).quantity];}catch{return [`${subject.key}:${unit.id}`,null];}})));
|
||||
const nutritionMappingMap=new Map(projection.sourceMappings.map(mapping=>[mapping.id,mapping]));
|
||||
const recipeConversions = database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[];
|
||||
const additional = database.prepare("SELECT station,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
|
||||
const additionalSource = JSON.parse(additional.source_json ?? "{}"), shelfLife = additionalSource.shelf_life;
|
||||
|
||||
const calcContext = getRecipeCalculationContext(database, id);
|
||||
if (!calcContext) { database.close(); return new Response("Recipe calculation context not found", { status: 404 }); }
|
||||
const { domainRecipe, recipes: recipeMap, ingredients: ingredientMap, units: unitMap, purchaseItems: purchaseItemsMap, sourceMappings: sourceMappingsMap, prepActions: prepActionsMap } = calcContext;
|
||||
|
||||
const nutrition = calculateNutrition(domainRecipe, { recipes: recipeMap, ingredients: ingredientMap, units: unitMap, mappings: sourceMappingsMap });
|
||||
const cost = calculateCost(domainRecipe, { recipes: recipeMap, ingredients: ingredientMap, units: unitMap, purchaseItems: purchaseItemsMap, prepActions: prepActionsMap });
|
||||
const reviewedNutritionMappingIds = new Set(Array.from(sourceMappingsMap.values()).filter((mapping) => mapping.mapping_type === "nutrition" && mapping.status === "reviewed" && Object.keys(mapping.nutrition_per_100g ?? {}).length > 0).map((mapping) => mapping.id));
|
||||
const calculatorComponents = domainRecipe.components.map((component) => ({ ...component, items: component.items.map((item) => { const ingredient = "ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined; const child = "recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; const mapped = ingredient ? (ingredient.nutrition_mapping_ids ?? []).some((mappingId) => reviewedNutritionMappingIds.has(mappingId)) : true; return { ...item, basisMember: item.basis_member, label: ingredient?.name ?? child?.title ?? "Unknown", href: ingredient ? `/app/ingredients/${ingredient.id}/` : child ? `/app/recipes/${child.id}/` : undefined, attention: Boolean(ingredient && !mapped), attentionMessage: ingredient && !mapped ? "Nutrition mapping needed" : undefined, measureConversions: ingredient?.measure_conversions ?? child?.measure_conversions ?? [] }; }) }));
|
||||
const percentSubjectEntries = domainRecipe.components.flatMap(component => component.items).map(item => { const ingredient = "ingredient_id" in item.reference ? ingredientMap.get(item.reference.ingredient_id) : undefined, child = "recipe_id" in item.reference ? recipeMap.get(item.reference.recipe_id) : undefined; return ingredient ? [`ingredient:${ingredient.id}`, { key: `ingredient:${ingredient.id}`, value: ingredient }] as [string, { key: string, value: Ingredient | Recipe }] : [`recipe:${child?.id}`, { key: `recipe:${child?.id}`, value: child! }] as [string, { key: string, value: Ingredient | Recipe }]; });
|
||||
const percentSubjects = [...new Map<string, { key: string, value: Ingredient | Recipe }>(percentSubjectEntries).values()];
|
||||
const weightRates = Object.fromEntries(percentSubjects.flatMap(subject => Array.from(unitMap.values()).map(unit => { try { return [`${subject.key}:${unit.id}`, convertWithIngredientMeasures({ quantity: 1, unit_id: unit.id }, "gram", subject.value as Ingredient, unitMap).quantity]; } catch { return [`${subject.key}:${unit.id}`, null]; } })));
|
||||
const nutritionMappingMap = sourceMappingsMap;
|
||||
const nutritionIngredients=domainRecipe.components.flatMap(component=>component.items).filter(item=>!item.optional).map(item=>{
|
||||
if("ingredient_id" in item.reference){
|
||||
const ingredient=ingredientMap.get(item.reference.ingredient_id)!;
|
||||
|
||||
@@ -6,13 +6,17 @@ 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";
|
||||
import { openDatabase } from "../../../lib/database";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
|
||||
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 database = openDatabase();
|
||||
const ingredientOptions = database
|
||||
? (database.prepare("SELECT id, name FROM ingredients WHERE status = 'active' ORDER BY name").all() as Array<{ id: string; name: string }>).map((i) => ({ id: i.id, name: titleCase(i.name) }))
|
||||
: [];
|
||||
if (database) database.close();
|
||||
---
|
||||
|
||||
<BaseLayout title="Purchasing review" immersive>
|
||||
|
||||
@@ -2,19 +2,42 @@
|
||||
interface Props { section?: string; sectionHref?: string }
|
||||
import { readOnlyMode } from "../lib/runtime";
|
||||
const { section, sectionHref } = Astro.props;
|
||||
const inferred = Astro.url.pathname.includes("/ingredients/")
|
||||
? { label:"Ingredients", href:"/app/?type=ingredient" }
|
||||
: Astro.url.pathname.includes("/recipe-books/")
|
||||
? { label:"Recipe Books", href:"/app/?type=book" }
|
||||
: Astro.url.pathname.includes("/recipes/")
|
||||
? { label:"Recipes", href:"/app/?type=recipe" }
|
||||
: { label:undefined, href:"/app/" };
|
||||
const resolvedSection=section??inferred.label;
|
||||
const resolvedHref=sectionHref??inferred.href;
|
||||
const pathname = Astro.url.pathname.replace(/\/+$/, "") + "/";
|
||||
|
||||
let inferredLabel: string | undefined;
|
||||
let inferredHref = "/app/";
|
||||
|
||||
if (pathname.startsWith("/app/inventory/")) {
|
||||
if (pathname === "/app/inventory/") {
|
||||
inferredLabel = "Inventory";
|
||||
inferredHref = "/app/";
|
||||
} else {
|
||||
inferredLabel = "Inventory";
|
||||
inferredHref = "/app/inventory/";
|
||||
}
|
||||
} else if (pathname.startsWith("/app/ingredients/")) {
|
||||
inferredLabel = "Ingredients";
|
||||
inferredHref = "/app/?type=ingredient";
|
||||
} else if (pathname.startsWith("/app/recipe-books/")) {
|
||||
inferredLabel = "Recipe Books";
|
||||
inferredHref = "/app/?type=book";
|
||||
} else if (pathname.startsWith("/app/recipes/")) {
|
||||
inferredLabel = "Recipes";
|
||||
inferredHref = "/app/?type=recipe";
|
||||
} else if (pathname.startsWith("/app/archive")) {
|
||||
inferredLabel = "Archive";
|
||||
inferredHref = "/app/";
|
||||
} else if (pathname.startsWith("/tools/purchasing-review")) {
|
||||
inferredLabel = "Purchasing Review";
|
||||
inferredHref = "/app/";
|
||||
}
|
||||
|
||||
const resolvedSection = section ?? inferredLabel;
|
||||
const resolvedHref = sectionHref ?? inferredHref;
|
||||
---
|
||||
<nav class="detail-utility" aria-label="Entity navigation">
|
||||
<div class="detail-context">
|
||||
<a class="detail-back" href={resolvedHref} aria-label={`Back to ${resolvedSection??"home"}`}>‹</a>
|
||||
<a class="detail-back" href={resolvedHref} aria-label={`Back to ${resolvedSection??"home"}`} data-back-link>‹</a>
|
||||
<a class="detail-avatar" href="/app/" aria-label="Recipe Book home">RB</a>
|
||||
<ol class="detail-breadcrumbs">
|
||||
<li><a href="/app/">Home</a></li>
|
||||
@@ -59,4 +82,13 @@ const resolvedHref=sectionHref??inferred.href;
|
||||
});
|
||||
sync();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.detail-back[data-back-link]').forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
if (window.history.length > 1 && document.referrer && new URL(document.referrer, window.location.origin).origin === window.location.origin) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -89,7 +89,6 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
value={roundForDisplay(factor)}
|
||||
onInput={(event) => setFactor(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
<span class="scale-suffix">x</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="basis-input finished-yield-field">
|
||||
@@ -195,15 +194,78 @@ function useLiveFactor() {
|
||||
|
||||
function CostLedgerLine({ line, factor, currency, editable, expanded }: { line:CostLine; factor:number; currency:string; editable:boolean; expanded:boolean }) {
|
||||
const money=new Intl.NumberFormat("en-US",{style:"currency",currency,minimumFractionDigits:2,maximumFractionDigits:4});
|
||||
const href=line.kind==="ingredient"?`/app/ingredients/${line.subjectId}/${editable?"?edit=1#costs":"#costs"}`:`/app/recipes/${line.subjectId}/#costing`;
|
||||
const body=<>
|
||||
<span class={`cost-subject-icon ${line.kind}`}>{line.kind==="recipe"?"R":"●"}</span>
|
||||
<a href={href}>{line.name}</a>
|
||||
{line.completeness<1&&<span class="cost-attention" title="Cost information is incomplete" aria-label="Cost information is incomplete"><svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg></span>}
|
||||
<span class="cost-line-value">{line.cost!=null?money.format(line.cost*factor):line.kind==="ingredient"&&editable?<a href={href}>Add cost ✎</a>:"—"}</span>
|
||||
</>;
|
||||
if(line.purchase||line.children?.length)return <details class="cost-ledger-line" open={expanded}><summary>{body}</summary><div class="cost-line-detail">{line.purchase?<><div><small>Purchase item name</small><strong>{line.purchase.name}</strong></div><div><small>Purchase cost</small><strong>{money.format(line.purchase.price)}</strong></div><div><small>Purchase unit</small><strong>{number(line.purchase.packageQuantity)} {line.purchase.packageUnitId}</strong></div><div><small>Date added</small><strong>{line.purchase.effectiveAt}</strong></div><div><small>Item ID #</small><strong>{line.purchase.sku??"—"}</strong></div><div><small>Vendor</small><strong>{line.purchase.supplier??"—"}</strong></div></>:<p>No usable purchase cost is available.</p>}{line.children?.length?<div class="cost-child-lines">{line.children.map(child=><CostLedgerLine line={child} factor={factor} currency={currency} editable={editable} expanded={expanded}/>)}</div>:null}</div></details>;
|
||||
return <div class="cost-ledger-line flat">{body}</div>;
|
||||
const editHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/?edit=1#costs` : `/app/recipes/${line.subjectId}/?edit=1#costing`;
|
||||
const viewHref = line.kind === "ingredient" ? `/app/ingredients/${line.subjectId}/#costs` : `/app/recipes/${line.subjectId}/#costing`;
|
||||
const hasChildren = Boolean(line.purchase || line.children?.length);
|
||||
const costValue = line.cost != null ? money.format(line.cost * factor) : "—";
|
||||
|
||||
const handleSummaryClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest("a") || target?.closest("button")) {
|
||||
return;
|
||||
}
|
||||
if (!hasChildren) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<details class={`cost-ledger-line ${hasChildren ? "has-children" : "flat"}`} open={expanded}>
|
||||
<summary onClick={handleSummaryClick}>
|
||||
<span class="cost-toggle-marker" aria-hidden="true">
|
||||
{hasChildren && (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" class="chevron-icon">
|
||||
<path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span class={`cost-subject-icon ${line.kind}`}>{line.kind==="recipe"?"R":"●"}</span>
|
||||
<a href={editable ? editHref : viewHref} class="cost-subject-name" onClick={(e) => e.stopPropagation()}>{line.name}</a>
|
||||
<span class="cost-attention-slot">
|
||||
{line.completeness < 1 && (
|
||||
<span class="cost-attention-badge" title="Cost information is incomplete (missing purchase item or price)" aria-label="Cost information is incomplete">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span class="cost-line-value">
|
||||
{line.kind === "ingredient" ? (
|
||||
<a href={editHref} class="cost-editable-link" title={line.cost != null ? "Edit purchase cost" : "Add purchase cost"} onClick={(e) => e.stopPropagation()}>
|
||||
<span>{line.cost != null ? costValue : "Add cost"}</span>
|
||||
<span class="cost-edit-icon" aria-hidden="true">✎</span>
|
||||
</a>
|
||||
) : (
|
||||
<span>{costValue}</span>
|
||||
)}
|
||||
</span>
|
||||
</summary>
|
||||
{hasChildren && (
|
||||
<div class="cost-line-detail">
|
||||
{line.purchase ? (
|
||||
<>
|
||||
<div><small>Purchase item name</small><strong>{line.purchase.name}</strong></div>
|
||||
<div><small>Purchase cost</small><strong>{money.format(line.purchase.price)}</strong></div>
|
||||
<div><small>Purchase unit</small><strong>{number(line.purchase.packageQuantity)} {line.purchase.packageUnitId}</strong></div>
|
||||
<div><small>Date added</small><strong>{line.purchase.effectiveAt}</strong></div>
|
||||
<div><small>Item ID #</small><strong>{line.purchase.sku??"—"}</strong></div>
|
||||
<div><small>Vendor</small><strong>{line.purchase.supplier??"—"}</strong></div>
|
||||
</>
|
||||
) : (
|
||||
<p>No usable purchase cost is available.</p>
|
||||
)}
|
||||
{line.children?.length ? (
|
||||
<div class="cost-child-lines">
|
||||
{line.children.map((child) => (
|
||||
<CostLedgerLine line={child} factor={factor} currency={currency} editable={editable} expanded={expanded}/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveCostValues({ cost, yieldQuantity, yieldUnit="g", editable=false }: { cost: CostResult; yieldQuantity?:number; yieldUnit?:string; editable?:boolean }) {
|
||||
@@ -211,7 +273,40 @@ export function LiveCostValues({ cost, yieldQuantity, yieldUnit="g", editable=fa
|
||||
const money=new Intl.NumberFormat("en-US",{style:"currency",currency:cost.currency,minimumFractionDigits:2,maximumFractionDigits:4});
|
||||
const [expansion,setExpansion]=useState({open:false,revision:0});
|
||||
const setAll=(open:boolean)=>setExpansion((current)=>({open,revision:current.revision+1}));
|
||||
return <section class="recipe-cost-ledger"><header><h2>Recipe Cost</h2><p>{editable?"Update an ingredient’s shared purchase cost here. The change is reflected in every recipe that uses it.":"Ingredient and sub-recipe costs used to calculate this recipe."}</p></header><div class="cost-ledger-heading"><span>Ingredient / Sub-Recipe <button type="button" onClick={()=>setAll(true)}>Expand all</button><i>|</i><button type="button" onClick={()=>setAll(false)}>Collapse all</button></span><span>Cost</span></div><div class={expansion.open?"cost-ledger-lines expand-all":"cost-ledger-lines"}>{cost.lines.map(line=><CostLedgerLine key={`${line.id}:${expansion.revision}`} line={line} factor={factor} currency={cost.currency} editable={editable} expanded={expansion.open}/>)}</div><div class="cost-summary"><div><strong>Total Yield</strong><span>{yieldQuantity!=null?number(yieldQuantity*factor):"—"} <small>{yieldUnit}</small></span></div><div><strong>Total Cost</strong><span>{cost.batch!=null?money.format(cost.batch*factor):"—"}</span></div><div><strong>Cost Per {yieldUnit.toUpperCase()}:</strong><span>{cost.batch!=null&&yieldQuantity?money.format(cost.batch/yieldQuantity):"—"}</span></div>{cost.perServing!=null&&<div><strong>Cost Per Serving</strong><span>{money.format(cost.perServing)}</span></div>}</div>{cost.completeness<1&&<p class="derived-warning">Partial estimate; unpriced ingredients are excluded. {Math.round(cost.completeness*100)}% of ingredient weight is priced.</p>}{cost.warnings.length>0&&<details class="cost-diagnostics"><summary>{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}</summary><ul>{cost.warnings.map(warning=><li>{warning}</li>)}</ul></details>}</section>;
|
||||
|
||||
return (
|
||||
<section class="recipe-cost-ledger">
|
||||
<header>
|
||||
<h2>Recipe Cost</h2>
|
||||
<p>{editable?"Update an ingredient’s shared purchase cost here. The change is reflected in every recipe that uses it.":"Ingredient and sub-recipe costs used to calculate this recipe."}</p>
|
||||
</header>
|
||||
<div class="cost-ledger-heading">
|
||||
<span class="head-spacer" aria-hidden="true"></span>
|
||||
<span class="head-icon-spacer" aria-hidden="true"></span>
|
||||
<span class="head-subject">
|
||||
Ingredient / Sub-Recipe{" "}
|
||||
<button type="button" onClick={()=>setAll(true)}>Expand all</button>
|
||||
<i>|</i>
|
||||
<button type="button" onClick={()=>setAll(false)}>Collapse all</button>
|
||||
</span>
|
||||
<span class="head-attention-spacer" aria-hidden="true"></span>
|
||||
<span class="head-cost">Cost</span>
|
||||
</div>
|
||||
<div class={expansion.open?"cost-ledger-lines expand-all":"cost-ledger-lines"}>
|
||||
{cost.lines.map((line)=>(
|
||||
<CostLedgerLine key={`${line.id}:${expansion.revision}`} line={line} factor={factor} currency={cost.currency} editable={editable} expanded={expansion.open}/>
|
||||
))}
|
||||
</div>
|
||||
<div class="cost-summary">
|
||||
<div><strong>Total Yield</strong><span>{yieldQuantity!=null?number(yieldQuantity*factor):"—"} <small>{yieldUnit}</small></span></div>
|
||||
<div><strong>Total Cost</strong><span>{cost.batch!=null?money.format(cost.batch*factor):"—"}</span></div>
|
||||
<div><strong>Cost Per {yieldUnit.toUpperCase()}:</strong><span>{cost.batch!=null&&yieldQuantity?money.format(cost.batch/yieldQuantity):"—"}</span></div>
|
||||
{cost.perServing!=null&&<div><strong>Cost Per Serving</strong><span>{money.format(cost.perServing)}</span></div>}
|
||||
</div>
|
||||
{cost.completeness<1&&<p class="derived-warning">Partial estimate; unpriced ingredients are excluded. {Math.round(cost.completeness*100)}% of ingredient weight is priced.</p>}
|
||||
{cost.warnings.length>0&&<details class="cost-diagnostics"><summary>{cost.warnings.length} costing {cost.warnings.length===1?"issue":"issues"}</summary><ul>{cost.warnings.map(warning=><li>{warning}</li>)}</ul></details>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LiveNutritionValues({ nutrition,servings,ingredients=[],editable=false,saveVersion }: { nutrition:NutritionResult; servings?:number; ingredients?:import("./NutritionPanel").NutritionIngredientStatus[]; editable?:boolean; saveVersion?:number }) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
type Props = {
|
||||
open: boolean;
|
||||
text: string;
|
||||
error: string;
|
||||
parsing: boolean;
|
||||
onTextChange: (text: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export default function BulkIngredientImportModal({
|
||||
open,
|
||||
text,
|
||||
error,
|
||||
parsing,
|
||||
onTextChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="bulk-ingredient-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
class="bulk-ingredient-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-ingredient-title"
|
||||
>
|
||||
<button
|
||||
class="bulk-dialog-close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 id="bulk-ingredient-title">Add Ingredients</h2>
|
||||
<p>
|
||||
Type or copy/paste ingredients from a document, spreadsheet, PDF, or
|
||||
website.
|
||||
</p>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={text}
|
||||
onInput={(event) => onTextChange(event.currentTarget.value)}
|
||||
placeholder={
|
||||
"Dry Mix:\n500g flour\n1/2 cup semolina\nsalt to taste\n\nWet:\n5 cloves garlic\n3 egg yolks\nolive oil (room temp)"
|
||||
}
|
||||
/>
|
||||
{error && <p class="bulk-dialog-error">{error}</p>}
|
||||
<div class="bulk-entry-help">
|
||||
<span>
|
||||
Add headers <small>using a colon : eg To Garnish:</small>
|
||||
</span>
|
||||
<span>
|
||||
Add notes to ingredients <small>by putting them in (notes)</small>
|
||||
</span>
|
||||
</div>
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-cancel"
|
||||
disabled={parsing}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-submit"
|
||||
disabled={!text.trim() || parsing}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{parsing ? "Parsing…" : "Add Ingredients"}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
type Props = {
|
||||
open: boolean;
|
||||
text: string;
|
||||
error: string;
|
||||
onTextChange: (text: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export default function BulkPrepStepsModal({
|
||||
open,
|
||||
text,
|
||||
error,
|
||||
onTextChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="bulk-ingredient-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
class="bulk-ingredient-dialog bulk-prep-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-prep-title"
|
||||
>
|
||||
<button
|
||||
class="bulk-dialog-close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 id="bulk-prep-title">Add Prep Steps</h2>
|
||||
<p>
|
||||
Type or copy/paste prep steps from a document, spreadsheet, PDF, or
|
||||
website.
|
||||
</p>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={text}
|
||||
onInput={(event) => onTextChange(event.currentTarget.value)}
|
||||
placeholder={
|
||||
'Cut & Portion Pork Belly:\n1. Dice pork belly into 1" cubes\n2. Transfer into container\n\nTo Marinate:\n3. Mix gochujang, mustard, and oil\n4. Coat pork thoroughly and refrigerate'
|
||||
}
|
||||
/>
|
||||
{error && <p class="bulk-dialog-error">{error}</p>}
|
||||
<div class="bulk-entry-help">
|
||||
<span>
|
||||
Add headers <small>using a colon : eg To Sear:</small>
|
||||
</span>
|
||||
<span>
|
||||
Add notes to prep method <small>by putting them in (notes)</small>
|
||||
</span>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" class="bulk-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-submit"
|
||||
disabled={!text.trim()}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Add Prep Steps
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import type { DragState, Option, PercentMode } from "./types";
|
||||
|
||||
type Props = {
|
||||
item: RecipeStructure["components"][number]["items"][number];
|
||||
componentIndex: number;
|
||||
itemIndex: number;
|
||||
units: Array<Option & { symbol: string }>;
|
||||
ingredients: Option[];
|
||||
pendingIngredients: Option[];
|
||||
recipes: Option[];
|
||||
recipeId: string;
|
||||
dragging: DragState;
|
||||
calculatePercent: boolean;
|
||||
percentMode: PercentMode;
|
||||
percentValue: number | undefined;
|
||||
focusedItem: string | undefined;
|
||||
rowQueryValue: string | undefined;
|
||||
onUpdateQuantity: (quantity: number) => void;
|
||||
onUpdateUnit: (unitId: string) => void;
|
||||
onUpdateNotes: (notes: string) => void;
|
||||
onUpdateBasis: (basis: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onDragStart: (event: DragEvent) => void;
|
||||
onDrop: (event: DragEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSelectChoice: (
|
||||
kind: "ingredient" | "recipe",
|
||||
id: string,
|
||||
name: string
|
||||
) => void;
|
||||
onCreatePendingIngredient: (name: string) => void;
|
||||
onSetPercent: (target: number) => void;
|
||||
};
|
||||
|
||||
const normal = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
export default function RecipeItemRow({
|
||||
item,
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
units,
|
||||
ingredients,
|
||||
pendingIngredients,
|
||||
recipes,
|
||||
recipeId,
|
||||
dragging,
|
||||
calculatePercent,
|
||||
percentMode,
|
||||
percentValue,
|
||||
focusedItem,
|
||||
rowQueryValue,
|
||||
onUpdateQuantity,
|
||||
onUpdateUnit,
|
||||
onUpdateNotes,
|
||||
onUpdateBasis,
|
||||
onRemove,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onQueryChange,
|
||||
onSelectChoice,
|
||||
onCreatePendingIngredient,
|
||||
onSetPercent,
|
||||
}: Props) {
|
||||
const isPending = Boolean(
|
||||
item.ingredient_id &&
|
||||
pendingIngredients.some((entry) => entry.id === item.ingredient_id)
|
||||
);
|
||||
const label = item.ingredient_id
|
||||
? [...ingredients, ...pendingIngredients].find(
|
||||
(entry) => entry.id === item.ingredient_id
|
||||
)?.name
|
||||
: recipes.find((entry) => entry.id === item.subrecipe_id)?.name;
|
||||
const typedValue = rowQueryValue ?? label ?? "";
|
||||
const choices = [
|
||||
...[...ingredients, ...pendingIngredients].map((entry) => ({
|
||||
...entry,
|
||||
kind: "ingredient" as const,
|
||||
})),
|
||||
...recipes
|
||||
.filter((entry) => entry.id !== recipeId)
|
||||
.map((entry) => ({ ...entry, kind: "recipe" as const })),
|
||||
];
|
||||
const exactMatch = choices.some((entry) =>
|
||||
[entry.name, ...(entry.aliases ?? [])].some(
|
||||
(name) => normal(name) === normal(typedValue)
|
||||
)
|
||||
);
|
||||
const shownChoices = choices
|
||||
.filter(
|
||||
(entry) =>
|
||||
!typedValue.trim() ||
|
||||
[entry.name, ...(entry.aliases ?? [])].some((name) =>
|
||||
normal(name).includes(normal(typedValue))
|
||||
)
|
||||
)
|
||||
.slice(0, 10);
|
||||
const hasUncommittedInput = normal(typedValue) !== normal(label ?? "");
|
||||
const canCreateInput = Boolean(typedValue.trim()) && !exactMatch;
|
||||
const isSubrecipe = Boolean(item.subrecipe_id);
|
||||
|
||||
return (
|
||||
<tr
|
||||
class={`${
|
||||
dragging?.kind === "item" &&
|
||||
dragging.component === componentIndex &&
|
||||
dragging.index === itemIndex
|
||||
? "dragging"
|
||||
: ""
|
||||
}${isPending ? " pending-ingredient-row" : ""}${
|
||||
isSubrecipe ? " is-subrecipe-row" : ""
|
||||
}`}
|
||||
key={item.id}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<td class="quantity-column">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Quantity"
|
||||
value={item.quantity}
|
||||
onInput={(event) =>
|
||||
onUpdateQuantity(Number(event.currentTarget.value))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td class="unit-column">
|
||||
<select
|
||||
aria-label="Unit"
|
||||
value={item.unit_id}
|
||||
onChange={(event) => onUpdateUnit(event.currentTarget.value)}
|
||||
>
|
||||
{units.map((unit) => (
|
||||
<option value={unit.id} key={unit.id}>
|
||||
{unit.symbol || unit.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td class="entity-column">
|
||||
<div
|
||||
class={`row-entity-combobox${
|
||||
hasUncommittedInput ? " unmatched" : ""
|
||||
}${isSubrecipe ? " is-subrecipe" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={typedValue}
|
||||
placeholder="Ingredient or Recipe"
|
||||
aria-label={`Ingredient or recipe for row ${itemIndex + 1}`}
|
||||
aria-expanded={focusedItem === item.id}
|
||||
aria-autocomplete="list"
|
||||
onFocus={(event) => {
|
||||
onFocus();
|
||||
event.currentTarget.select();
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
onInput={(event) => onQueryChange(event.currentTarget.value)}
|
||||
/>
|
||||
{isPending && (
|
||||
<span
|
||||
class="ingredient-attention-icon"
|
||||
aria-label="New unmatched ingredient"
|
||||
title="New ingredient; details can be completed after saving"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="17"
|
||||
height="17"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{focusedItem === item.id && (
|
||||
<div class="row-entity-results" role="listbox">
|
||||
{shownChoices.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${entry.kind}-${entry.id}`}
|
||||
role="option"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() =>
|
||||
onSelectChoice(entry.kind, entry.id, entry.name)
|
||||
}
|
||||
>
|
||||
<strong>{entry.name}</strong>
|
||||
<small class={`badge-${entry.kind}`}>
|
||||
{entry.kind === "recipe" ? "Recipe" : "Ingredient"}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
{canCreateInput && (
|
||||
<div class="row-create-actions">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onCreatePendingIngredient(typedValue)}
|
||||
>
|
||||
+ Create “{typedValue}”
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isPending && (
|
||||
<small class="pending-ingredient-help">
|
||||
New ingredient · saved automatically
|
||||
</small>
|
||||
)}
|
||||
</td>
|
||||
<td class="notes-column">
|
||||
<input
|
||||
class="line-notes"
|
||||
aria-label={`${label} notes`}
|
||||
value={item.notes ?? ""}
|
||||
placeholder="Add Notes"
|
||||
onInput={(event) => onUpdateNotes(event.currentTarget.value)}
|
||||
/>
|
||||
</td>
|
||||
{calculatePercent && percentMode === "bakers" && (
|
||||
<td class="base-column">
|
||||
<input
|
||||
aria-label={`${label} baker's base member`}
|
||||
type="checkbox"
|
||||
checked={item.basis_member}
|
||||
onChange={(event) => onUpdateBasis(event.currentTarget.checked)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{calculatePercent && (
|
||||
<td class="percent-column">
|
||||
{percentValue == null ? (
|
||||
<span title="A weight equivalency is required">—</span>
|
||||
) : (
|
||||
<input
|
||||
aria-label={`${label} percentage`}
|
||||
type="number"
|
||||
min="0"
|
||||
max={
|
||||
percentMode === "standard" || item.basis_member
|
||||
? 99.999
|
||||
: undefined
|
||||
}
|
||||
step="any"
|
||||
value={Math.round(percentValue * 1000) / 1000}
|
||||
onInput={(event) => onSetPercent(Number(event.currentTarget.value))}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td class="actions-column">
|
||||
<div class="row-actions-group">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-row-btn"
|
||||
title="Delete"
|
||||
aria-label="Delete ingredient"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder"
|
||||
aria-label="Drag to reorder"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { createPortal } from "preact/compat";
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import type { DragState } from "./types";
|
||||
|
||||
type Props = {
|
||||
steps: RecipeStructure["steps"];
|
||||
methodTarget: HTMLElement | null;
|
||||
dragging: DragState;
|
||||
onStepsChange: (steps: RecipeStructure["steps"]) => void;
|
||||
onDragStart: (index: number) => void;
|
||||
onDrop: (fromIndex: number, toIndex: number) => void;
|
||||
onOpenBulkPrep: () => void;
|
||||
};
|
||||
|
||||
const uid = (prefix: string) => `${prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
export default function RecipeMethodEditor({
|
||||
steps,
|
||||
methodTarget,
|
||||
dragging,
|
||||
onStepsChange,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
onOpenBulkPrep,
|
||||
}: Props) {
|
||||
const content = (
|
||||
<section class="method-editor">
|
||||
<div class="method-header-row">
|
||||
<h2>
|
||||
Prep Method <small>{steps.length}</small>
|
||||
</h2>
|
||||
</div>
|
||||
<ol class="method-steps-list">
|
||||
{steps.map((step, index) => {
|
||||
const stepKind = step.instruction.trim().endsWith(":")
|
||||
? " prep-heading"
|
||||
: /^\(.+\)$/.test(step.instruction.trim())
|
||||
? " prep-note"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<li
|
||||
class={`method-step-card${
|
||||
dragging?.kind === "step" && dragging.index === index
|
||||
? " dragging"
|
||||
: ""
|
||||
}${stepKind}`}
|
||||
key={step.id}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragging?.kind === "step") onDrop(dragging.index, index);
|
||||
}}
|
||||
>
|
||||
<span class="step-num">{index + 1}.</span>
|
||||
<div class="step-body">
|
||||
<textarea
|
||||
rows={2}
|
||||
class="step-textarea"
|
||||
placeholder="Add Prep Step"
|
||||
value={step.instruction}
|
||||
onInput={(event) =>
|
||||
onStepsChange(
|
||||
steps.map((value, position) =>
|
||||
position === index
|
||||
? { ...value, instruction: event.currentTarget.value }
|
||||
: value
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="step-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-step-btn"
|
||||
title="Delete step"
|
||||
onClick={() =>
|
||||
onStepsChange(
|
||||
steps.filter((_, position) => position !== index)
|
||||
)
|
||||
}
|
||||
disabled={steps.length === 1}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder"
|
||||
draggable
|
||||
onDragStart={() => onDragStart(index)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<div class="method-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
onStepsChange([
|
||||
...steps,
|
||||
{ id: uid("step"), instruction: "New Section:", equipment_ids: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
Add Header
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
onStepsChange([
|
||||
...steps,
|
||||
{ id: uid("step"), instruction: "(Note)", equipment_ids: [] },
|
||||
])
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||||
</svg>
|
||||
Add Note
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-prep-btn"
|
||||
onClick={onOpenBulkPrep}
|
||||
>
|
||||
Add Prep Steps
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return methodTarget ? createPortal(content, methodTarget) : content;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./types";
|
||||
export * from "./useRecipeStructure";
|
||||
export { default as RecipeItemRow } from "./RecipeItemRow";
|
||||
export { default as RecipeMethodEditor } from "./RecipeMethodEditor";
|
||||
export { default as BulkIngredientImportModal } from "./BulkIngredientImportModal";
|
||||
export { default as BulkPrepStepsModal } from "./BulkPrepStepsModal";
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
|
||||
export type Option = { id: string; name: string; aliases?: string[] };
|
||||
|
||||
export type RecipeEditorProps = {
|
||||
recipeId: string;
|
||||
initial: RecipeStructure;
|
||||
ingredients: Option[];
|
||||
recipes: Option[];
|
||||
units: Array<Option & { symbol: string }>;
|
||||
prepActions: Option[];
|
||||
autoYield?: boolean;
|
||||
showMethod?: boolean;
|
||||
weightRates?: Record<string, number | null>;
|
||||
};
|
||||
|
||||
export type DragState =
|
||||
| { kind: "component" | "item" | "step"; component?: number; index: number }
|
||||
| undefined;
|
||||
|
||||
export type EditorState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
export type PercentMode = "standard" | "bakers";
|
||||
@@ -0,0 +1,676 @@
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import { percentage, targetWeight } from "../../lib/percentages";
|
||||
import type {
|
||||
DragState,
|
||||
EditorState,
|
||||
Option,
|
||||
PercentMode,
|
||||
RecipeEditorProps,
|
||||
} from "./types";
|
||||
|
||||
export const uid = (prefix: string) =>
|
||||
`${prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
export const normal = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
const unitAliases: Record<string, string> = {
|
||||
g: "gram", gram: "gram", grams: "gram", kg: "kilogram", kilogram: "kilogram", kilograms: "kilogram",
|
||||
oz: "ounce_mass", ounce: "ounce_mass", ounces: "ounce_mass", lb: "pound", lbs: "pound", pound: "pound", pounds: "pound",
|
||||
tsp: "teaspoon_us", teaspoon: "teaspoon_us", teaspoons: "teaspoon_us", tbsp: "tablespoon_us", tablespoon: "tablespoon_us", tablespoons: "tablespoon_us",
|
||||
c: "cup_us", cup: "cup_us", cups: "cup_us", ml: "milliliter", milliliter: "milliliter", milliliters: "milliliter",
|
||||
l: "liter", liter: "liter", liters: "liter", ea: "each", each: "each", clove: "each", cloves: "each",
|
||||
};
|
||||
|
||||
export function useRecipeStructure({
|
||||
recipeId,
|
||||
initial,
|
||||
ingredients,
|
||||
recipes,
|
||||
units,
|
||||
autoYield = false,
|
||||
weightRates = {},
|
||||
}: RecipeEditorProps) {
|
||||
const [data, setData] = useState(initial);
|
||||
const [query, setQuery] = useState<Record<string, string>>({});
|
||||
const [rowQuery, setRowQuery] = useState<Record<string, string>>({});
|
||||
const [focusedItem, setFocusedItem] = useState<string>();
|
||||
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({});
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkError, setBulkError] = useState("");
|
||||
const [bulkParsing, setBulkParsing] = useState(false);
|
||||
const [prepOpen, setPrepOpen] = useState(false);
|
||||
const [prepText, setPrepText] = useState("");
|
||||
const [prepError, setPrepError] = useState("");
|
||||
const [pendingIngredients, setPendingIngredients] = useState<Option[]>([]);
|
||||
const [methodTarget, setMethodTarget] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMethodTarget(document.getElementById("recipe-method-editor-slot"));
|
||||
}, []);
|
||||
|
||||
const [baseline, setBaseline] = useState(JSON.stringify(initial));
|
||||
const [state, setState] = useState<EditorState>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
const [dragging, setDragging] = useState<DragState>();
|
||||
const [calculatePercent, setCalculatePercent] = useState(true);
|
||||
const [percentMode, setPercentMode] = useState<PercentMode>("standard");
|
||||
|
||||
const updateComponent = (
|
||||
index: number,
|
||||
update: (
|
||||
component: RecipeStructure["components"][number]
|
||||
) => RecipeStructure["components"][number]
|
||||
) =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: current.components.map((component, position) =>
|
||||
position === index ? update(component) : component
|
||||
),
|
||||
}));
|
||||
|
||||
const allItems = () =>
|
||||
data.components.flatMap((component) => component.items);
|
||||
|
||||
const rate = (
|
||||
item: RecipeStructure["components"][number]["items"][number]
|
||||
) =>
|
||||
item.unit_id === "gram"
|
||||
? 1
|
||||
: weightRates[
|
||||
`${
|
||||
item.ingredient_id
|
||||
? `ingredient:${item.ingredient_id}`
|
||||
: `recipe:${item.subrecipe_id}`
|
||||
}:${item.unit_id}`
|
||||
];
|
||||
|
||||
const weight = (
|
||||
item: RecipeStructure["components"][number]["items"][number]
|
||||
) => {
|
||||
const value = rate(item);
|
||||
return value == null ? undefined : item.quantity * value;
|
||||
};
|
||||
|
||||
const calculatedYieldWeight = () =>
|
||||
allItems().reduce((sum, item) => sum + (weight(item) ?? 0), 0);
|
||||
|
||||
const unconvertedYieldItems = () =>
|
||||
allItems().filter((item) => weight(item) == null).length;
|
||||
|
||||
const denominator = () =>
|
||||
percentMode === "standard"
|
||||
? allItems().reduce((sum, item) => sum + (weight(item) ?? 0), 0)
|
||||
: allItems()
|
||||
.filter((item) => item.basis_member)
|
||||
.reduce((sum, item) => sum + (weight(item) ?? 0), 0);
|
||||
|
||||
const percent = (
|
||||
item: RecipeStructure["components"][number]["items"][number]
|
||||
) => percentage(weight(item), denominator());
|
||||
|
||||
const setPercent = (
|
||||
componentIndex: number,
|
||||
itemIndex: number,
|
||||
target: number
|
||||
) => {
|
||||
const item = data.components[componentIndex].items[itemIndex],
|
||||
itemRate = rate(item),
|
||||
grams = targetWeight(
|
||||
target,
|
||||
weight(item) ?? 0,
|
||||
denominator(),
|
||||
percentMode === "standard" || item.basis_member
|
||||
);
|
||||
if (itemRate == null || grams == null) return;
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex ? { ...line, quantity: grams / itemRate } : line
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const move = <T,>(items: T[], from: number, to: number) => {
|
||||
const result = [...items];
|
||||
const [entry] = result.splice(from, 1);
|
||||
result.splice(to, 0, entry);
|
||||
return result;
|
||||
};
|
||||
|
||||
const addLine = (componentIndex: number, selected: string) => {
|
||||
const componentId = data.components[componentIndex].id;
|
||||
const [kind, id] = selected.split(":", 2);
|
||||
updateComponent(componentIndex, (component) => ({
|
||||
...component,
|
||||
items: [
|
||||
...component.items,
|
||||
{
|
||||
id: uid("line"),
|
||||
...(kind === "ingredient"
|
||||
? { ingredient_id: id }
|
||||
: { subrecipe_id: id }),
|
||||
quantity: 1,
|
||||
unit_id: "gram",
|
||||
basis_member: false,
|
||||
optional: false,
|
||||
nutrition_retention_factor: 1,
|
||||
prep: [],
|
||||
...(draftNotes[componentId]?.trim()
|
||||
? { notes: draftNotes[componentId].trim() }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
}));
|
||||
setQuery((current) => ({
|
||||
...current,
|
||||
[data.components[componentIndex].id]: "",
|
||||
}));
|
||||
setDraftNotes((current) => ({ ...current, [componentId]: "" }));
|
||||
};
|
||||
|
||||
const replaceLineReference = (
|
||||
componentIndex: number,
|
||||
itemIndex: number,
|
||||
kind: "ingredient" | "recipe",
|
||||
id: string,
|
||||
name: string
|
||||
) => {
|
||||
const itemId = data.components[componentIndex].items[itemIndex].id;
|
||||
updateComponent(componentIndex, (component) => ({
|
||||
...component,
|
||||
items: component.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? {
|
||||
...line,
|
||||
ingredient_id: kind === "ingredient" ? id : undefined,
|
||||
subrecipe_id: kind === "recipe" ? id : undefined,
|
||||
}
|
||||
: line
|
||||
),
|
||||
}));
|
||||
setRowQuery((current) => ({ ...current, [itemId]: name }));
|
||||
setFocusedItem(undefined);
|
||||
};
|
||||
|
||||
const createPendingRowIngredient = (
|
||||
componentIndex: number,
|
||||
itemIndex: number,
|
||||
name: string
|
||||
) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const existing = [...ingredients, ...pendingIngredients].find(
|
||||
(ingredient) => normal(ingredient.name) === normal(trimmed)
|
||||
);
|
||||
if (existing) {
|
||||
replaceLineReference(
|
||||
componentIndex,
|
||||
itemIndex,
|
||||
"ingredient",
|
||||
existing.id,
|
||||
existing.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((ingredient) => ingredient.id)
|
||||
);
|
||||
const base = normal(trimmed).replace(/ /g, "_") || "new_ingredient";
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
setPendingIngredients((current) => [...current, { id, name: trimmed }]);
|
||||
replaceLineReference(componentIndex, itemIndex, "ingredient", id, trimmed);
|
||||
};
|
||||
|
||||
const createPendingIngredient = (componentIndex: number, name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const existing = [...ingredients, ...pendingIngredients].find(
|
||||
(ingredient) => normal(ingredient.name) === normal(trimmed)
|
||||
);
|
||||
if (existing) {
|
||||
addLine(componentIndex, `ingredient:${existing.id}`);
|
||||
return;
|
||||
}
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((ingredient) => ingredient.id)
|
||||
);
|
||||
const base = normal(trimmed).replace(/ /g, "_") || "new_ingredient";
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
setPendingIngredients((current) => [...current, { id, name: trimmed }]);
|
||||
addLine(componentIndex, `ingredient:${id}`);
|
||||
};
|
||||
|
||||
const identityTokens = (value: string) =>
|
||||
normal(value)
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((token) =>
|
||||
token.length > 3 && token.endsWith("s") ? token.slice(0, -1) : token
|
||||
);
|
||||
|
||||
const addBulkIngredients = async () => {
|
||||
if (!bulkText.trim()) {
|
||||
setBulkError("Enter at least one ingredient.");
|
||||
return;
|
||||
}
|
||||
setBulkParsing(true);
|
||||
setBulkError("");
|
||||
let parsed: {
|
||||
components: Array<{
|
||||
name: string;
|
||||
items: Array<{
|
||||
source_line: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
ingredient: string;
|
||||
preparation: string | null;
|
||||
note: string | null;
|
||||
optional: boolean;
|
||||
alternatives: string[];
|
||||
}>;
|
||||
}>;
|
||||
warnings: string[];
|
||||
};
|
||||
try {
|
||||
const response = await fetch("/api/app/recipes/parse-ingredients", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text: bulkText }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(result.error ?? "Unable to parse ingredients.");
|
||||
parsed = result;
|
||||
} catch (error) {
|
||||
setBulkError(
|
||||
error instanceof Error ? error.message : "Unable to parse ingredients."
|
||||
);
|
||||
setBulkParsing(false);
|
||||
return;
|
||||
}
|
||||
const available = [
|
||||
...[...ingredients, ...pendingIngredients].map((item) => ({
|
||||
...item,
|
||||
kind: "ingredient" as const,
|
||||
})),
|
||||
...recipes
|
||||
.filter((item) => item.id !== recipeId)
|
||||
.map((item) => ({ ...item, kind: "recipe" as const })),
|
||||
];
|
||||
const components: RecipeStructure["components"] = [];
|
||||
const unmatched: string[] = [];
|
||||
const created: Option[] = [];
|
||||
const takenIds = new Set(
|
||||
[...ingredients, ...pendingIngredients].map((item) => item.id)
|
||||
);
|
||||
const newIngredient = (name: string) => {
|
||||
const base = normal(name).replace(/ /g, "_") || "imported_ingredient";
|
||||
let id = base,
|
||||
suffix = 2;
|
||||
while (takenIds.has(id)) id = `${base}_${suffix++}`;
|
||||
takenIds.add(id);
|
||||
const ingredient = { id, name: name.trim() };
|
||||
created.push(ingredient);
|
||||
const option = { ...ingredient, kind: "ingredient" as const };
|
||||
available.push(option);
|
||||
return option;
|
||||
};
|
||||
for (const parsedComponent of parsed.components) {
|
||||
const component = {
|
||||
id: uid("component"),
|
||||
name: parsedComponent.name || "Main",
|
||||
items: [],
|
||||
notes: [],
|
||||
} as RecipeStructure["components"][number];
|
||||
components.push(component);
|
||||
for (const parsedItem of parsedComponent.items) {
|
||||
const wanted = normal(parsedItem.ingredient);
|
||||
const tokens = identityTokens(wanted);
|
||||
const reducedTokens = tokens.filter(
|
||||
(token) =>
|
||||
![
|
||||
"fresh",
|
||||
"dried",
|
||||
"flake",
|
||||
"leave",
|
||||
"chopped",
|
||||
"minced",
|
||||
"sliced",
|
||||
"crushed",
|
||||
"granulated",
|
||||
].includes(token)
|
||||
);
|
||||
const labels = (item: (typeof available)[number]) =>
|
||||
[item.name, ...(item.aliases ?? [])].map(normal);
|
||||
const labelTokens = (item: (typeof available)[number]) =>
|
||||
labels(item).map(identityTokens);
|
||||
const match =
|
||||
available.find((item) => labels(item).includes(wanted)) ??
|
||||
available.find((item) =>
|
||||
labels(item).some(
|
||||
(label) => label.includes(wanted) || wanted.includes(label)
|
||||
)
|
||||
) ??
|
||||
available.find((item) =>
|
||||
labelTokens(item).some((label) =>
|
||||
tokens.every((token) => label.includes(token))
|
||||
)
|
||||
) ??
|
||||
available.find(
|
||||
(item) =>
|
||||
reducedTokens.length &&
|
||||
labelTokens(item).some((label) =>
|
||||
reducedTokens.every((token) => label.includes(token))
|
||||
)
|
||||
) ??
|
||||
newIngredient(parsedItem.ingredient);
|
||||
const unitId = parsedItem.unit
|
||||
? unitAliases[normal(parsedItem.unit)]
|
||||
: "each";
|
||||
if (!match || !unitId || !units.some((unit) => unit.id === unitId)) {
|
||||
unmatched.push(parsedItem.source_line);
|
||||
continue;
|
||||
}
|
||||
const notes = [
|
||||
parsedItem.preparation,
|
||||
parsedItem.note,
|
||||
parsedItem.alternatives.length
|
||||
? `Alternatives: ${parsedItem.alternatives.join("; ")}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
component.items.push({
|
||||
id: uid("line"),
|
||||
...(match.kind === "ingredient"
|
||||
? { ingredient_id: match.id }
|
||||
: { subrecipe_id: match.id }),
|
||||
quantity: parsedItem.quantity ?? 1,
|
||||
unit_id: unitId,
|
||||
basis_member: false,
|
||||
optional: parsedItem.optional,
|
||||
nutrition_retention_factor: 1,
|
||||
prep: [],
|
||||
...(notes ? { notes } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const populated = components.filter((entry) => entry.items.length > 0);
|
||||
if (unmatched.length || !populated.length) {
|
||||
setBulkError(
|
||||
unmatched.length
|
||||
? `Could not match: ${unmatched.join("; ")}`
|
||||
: "No ingredients could be matched."
|
||||
);
|
||||
setBulkParsing(false);
|
||||
return;
|
||||
}
|
||||
setPendingIngredients((current) => [...current, ...created]);
|
||||
setData((current) => {
|
||||
const additions = [...populated];
|
||||
const currentComponents = current.components.map((component) => ({
|
||||
...component,
|
||||
items: [...component.items],
|
||||
}));
|
||||
if (
|
||||
additions[0]?.name.toLowerCase() === "main" &&
|
||||
currentComponents.length
|
||||
)
|
||||
currentComponents.at(-1)!.items.push(...additions.shift()!.items);
|
||||
return { ...current, components: [...currentComponents, ...additions] };
|
||||
});
|
||||
const creationMessage = created.length
|
||||
? `${created.length} new canonical ingredient${
|
||||
created.length === 1 ? " is" : "s are"
|
||||
} pending (${created.map((item) => item.name).join(", ")}) and will be created when you save. `
|
||||
: "";
|
||||
setMessage(
|
||||
`${creationMessage}${
|
||||
parsed.warnings.length
|
||||
? `Parser warnings: ${parsed.warnings.join(" ")}`
|
||||
: "Review imported ingredients before saving."
|
||||
}`
|
||||
);
|
||||
setBulkText("");
|
||||
setBulkError("");
|
||||
setBulkOpen(false);
|
||||
setBulkParsing(false);
|
||||
};
|
||||
|
||||
const addBulkPrepSteps = () => {
|
||||
const lines = prepText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) {
|
||||
setPrepError("Field is required");
|
||||
return;
|
||||
}
|
||||
const additions = lines.map((instruction) => ({
|
||||
id: uid("step"),
|
||||
instruction,
|
||||
equipment_ids: [],
|
||||
}));
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps:
|
||||
current.steps.length === 1 && !current.steps[0].instruction.trim()
|
||||
? additions
|
||||
: [...current.steps, ...additions],
|
||||
}));
|
||||
setPrepText("");
|
||||
setPrepError("");
|
||||
setPrepOpen(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const unresolvedRow = data.components
|
||||
.flatMap((component) => component.items)
|
||||
.find((item) => {
|
||||
const typed = rowQuery[item.id];
|
||||
if (typed == null) return false;
|
||||
const currentLabel = item.ingredient_id
|
||||
? [...ingredients, ...pendingIngredients].find(
|
||||
(entry) => entry.id === item.ingredient_id
|
||||
)?.name
|
||||
: recipes.find((entry) => entry.id === item.subrecipe_id)?.name;
|
||||
return normal(typed) !== normal(currentLabel ?? "");
|
||||
});
|
||||
if (unresolvedRow) {
|
||||
setState("error");
|
||||
setMessage(
|
||||
"Choose a search result or create the unmatched ingredient before saving."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
setState("saving");
|
||||
setMessage("");
|
||||
const detailsForm =
|
||||
document.querySelector<HTMLFormElement>("#recipe-details-form");
|
||||
const details = detailsForm ? new FormData(detailsForm) : undefined;
|
||||
const additionalForm = document.querySelector<HTMLFormElement>(
|
||||
"#recipe-additional-form"
|
||||
);
|
||||
const additional = additionalForm ? new FormData(additionalForm) : undefined;
|
||||
const servingsText = String(details?.get("yield_servings") ?? "").trim();
|
||||
const shelfQuantityText = String(
|
||||
additional?.get("shelf_quantity") ?? ""
|
||||
).trim();
|
||||
const shelfUnit = String(additional?.get("shelf_unit") ?? "").trim();
|
||||
const payload = {
|
||||
...data,
|
||||
new_ingredients: pendingIngredients.map(({ id, name }) => ({ id, name })),
|
||||
...(details
|
||||
? {
|
||||
metadata: {
|
||||
title: String(details.get("title") ?? "").trim(),
|
||||
yield_quantity: Number(details.get("yield_quantity")),
|
||||
yield_unit_id: String(details.get("yield_unit_id") ?? ""),
|
||||
yield_servings: servingsText ? Number(servingsText) : null,
|
||||
yield_basis:
|
||||
String(details.get("yield_basis") ?? "").trim() || null,
|
||||
station: String(additional?.get("station") ?? "").trim() || null,
|
||||
cover_media_url:
|
||||
String(additional?.get("cover_media_url") ?? "").trim() || null,
|
||||
tags: String(additional?.get("tags") ?? "")
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
shelf_life:
|
||||
shelfQuantityText && shelfUnit
|
||||
? {
|
||||
quantity: Number(shelfQuantityText),
|
||||
unit: shelfUnit,
|
||||
storage_condition:
|
||||
String(
|
||||
additional?.get("storage_condition") ?? ""
|
||||
).trim() || undefined,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
components: data.components.map((component) => ({
|
||||
...component,
|
||||
items: component.items.map((item) => ({
|
||||
...item,
|
||||
...(calculatePercent ? { percentage: percent(item) } : {}),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const response = await fetch(`/api/app/recipes/${recipeId}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
setState("error");
|
||||
setMessage(result.error ?? "Unable to save.");
|
||||
return false;
|
||||
}
|
||||
setData(result);
|
||||
setPendingIngredients([]);
|
||||
setBaseline(JSON.stringify(result));
|
||||
const saveVersionInput = document.querySelector<HTMLInputElement>(
|
||||
'#recipe-details-form input[name="save_version"]'
|
||||
);
|
||||
if (saveVersionInput) saveVersionInput.value = String(result.save_version);
|
||||
setState("saved");
|
||||
setMessage("Changes saved.");
|
||||
document.dispatchEvent(new CustomEvent("recipe:dirty", { detail: false }));
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleDone = (event: Event) => {
|
||||
const detail = (
|
||||
event as CustomEvent<{ complete: (saved: boolean) => void }>
|
||||
).detail;
|
||||
void save().then(detail.complete);
|
||||
};
|
||||
document.addEventListener("recipe:save-structure", handleDone);
|
||||
return () =>
|
||||
document.removeEventListener("recipe:save-structure", handleDone);
|
||||
}, [data, pendingIngredients, calculatePercent, percentMode]);
|
||||
|
||||
useEffect(() => {
|
||||
document.dispatchEvent(new CustomEvent("recipe:editor-ready"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bulkOpen && !prepOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setBulkOpen(false);
|
||||
setPrepOpen(false);
|
||||
}
|
||||
};
|
||||
document.body.style.overflow = "hidden";
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [bulkOpen, prepOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("recipe:dirty", {
|
||||
detail:
|
||||
JSON.stringify(data) !== baseline || pendingIngredients.length > 0,
|
||||
})
|
||||
);
|
||||
}, [data, baseline, pendingIngredients]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoYield) return;
|
||||
const quantity = calculatedYieldWeight();
|
||||
const quantityInput = document.querySelector<HTMLInputElement>(
|
||||
'#recipe-details-form input[name="yield_quantity"]'
|
||||
);
|
||||
const unitInput = document.querySelector<HTMLSelectElement>(
|
||||
'#recipe-details-form select[name="yield_unit_id"]'
|
||||
);
|
||||
if (quantityInput && quantity > 0)
|
||||
quantityInput.value = String(Math.round(quantity * 1000) / 1000);
|
||||
if (unitInput) unitInput.value = "gram";
|
||||
}, [data, autoYield]);
|
||||
|
||||
return {
|
||||
data,
|
||||
setData,
|
||||
query,
|
||||
setQuery,
|
||||
rowQuery,
|
||||
setRowQuery,
|
||||
focusedItem,
|
||||
setFocusedItem,
|
||||
draftNotes,
|
||||
setDraftNotes,
|
||||
bulkOpen,
|
||||
setBulkOpen,
|
||||
bulkText,
|
||||
setBulkText,
|
||||
bulkError,
|
||||
setBulkError,
|
||||
bulkParsing,
|
||||
prepOpen,
|
||||
setPrepOpen,
|
||||
prepText,
|
||||
setPrepText,
|
||||
prepError,
|
||||
setPrepError,
|
||||
pendingIngredients,
|
||||
methodTarget,
|
||||
baseline,
|
||||
state,
|
||||
message,
|
||||
dragging,
|
||||
setDragging,
|
||||
calculatePercent,
|
||||
setCalculatePercent,
|
||||
percentMode,
|
||||
setPercentMode,
|
||||
updateComponent,
|
||||
percent,
|
||||
setPercent,
|
||||
move,
|
||||
addLine,
|
||||
replaceLineReference,
|
||||
createPendingRowIngredient,
|
||||
createPendingIngredient,
|
||||
addBulkIngredients,
|
||||
addBulkPrepSteps,
|
||||
save,
|
||||
unconvertedYieldItems,
|
||||
};
|
||||
}
|
||||
+37
-16
@@ -1,19 +1,40 @@
|
||||
import type { Equipment, Ingredient, PrepAction, PurchaseItem, Recipe, Unit, SourceMapping } from "./types";
|
||||
import type {
|
||||
Equipment,
|
||||
Ingredient,
|
||||
PrepAction,
|
||||
PurchaseItem,
|
||||
Recipe,
|
||||
Unit,
|
||||
SourceMapping,
|
||||
} from "./types";
|
||||
import { databaseProjection, openDatabase } from "./database";
|
||||
|
||||
const database = openDatabase();
|
||||
if (!database) throw new Error("Database unavailable. Run npm run db:reset first.");
|
||||
const projection = databaseProjection(database) as {
|
||||
ingredients: Ingredient[]; recipes: Recipe[]; units: Unit[]; equipment: Equipment[];
|
||||
prepActions: PrepAction[]; purchaseItems: PurchaseItem[]; sourceMappings: SourceMapping[];
|
||||
};
|
||||
database.close();
|
||||
export function loadCatalogs() {
|
||||
const database = openDatabase();
|
||||
if (!database) throw new Error("Database unavailable. Run npm run db:reset first.");
|
||||
try {
|
||||
const projection = databaseProjection(database) as {
|
||||
ingredients: Ingredient[];
|
||||
recipes: Recipe[];
|
||||
units: Unit[];
|
||||
equipment: Equipment[];
|
||||
prepActions: PrepAction[];
|
||||
purchaseItems: PurchaseItem[];
|
||||
sourceMappings: SourceMapping[];
|
||||
};
|
||||
const map = <T extends { id: string }>(values: T[]) =>
|
||||
new Map(values.map((value) => [value.id, value]));
|
||||
|
||||
const map = <T extends { id: string }>(values: T[]) => new Map(values.map((value) => [value.id, value]));
|
||||
export const ingredients = map(projection.ingredients);
|
||||
export const recipes = map(projection.recipes);
|
||||
export const units = map(projection.units);
|
||||
export const equipment = map(projection.equipment);
|
||||
export const prepActions = map(projection.prepActions);
|
||||
export const purchaseItems = map(projection.purchaseItems);
|
||||
export const sourceMappings = map(projection.sourceMappings);
|
||||
return {
|
||||
ingredients: map(projection.ingredients),
|
||||
recipes: map(projection.recipes),
|
||||
units: map(projection.units),
|
||||
equipment: map(projection.equipment),
|
||||
prepActions: map(projection.prepActions),
|
||||
purchaseItems: map(projection.purchaseItems),
|
||||
sourceMappings: map(projection.sourceMappings),
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
duplicateRecipe,
|
||||
editableRecipe,
|
||||
permanentlyDeleteArchivedItems,
|
||||
recipeQualityRows,
|
||||
recipeStructure,
|
||||
restoreArchivedItems,
|
||||
saveRecipeMetadata,
|
||||
saveRecipeStructure,
|
||||
} from "./database";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const schemaPath = path.resolve(process.cwd(), "migrations", "001_initial.sql");
|
||||
const schemaSql = fs.readFileSync(schemaPath, "utf8");
|
||||
db.exec(schemaSql);
|
||||
|
||||
// Seed baseline units
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"gram", "Gram", "g", "mass", "metric", null, null, null
|
||||
);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"kilogram", "Kilogram", "kg", "mass", "metric", "gram", 1000, null
|
||||
);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"each", "Each", "ea", "count", "customary", null, null, null
|
||||
);
|
||||
|
||||
// Seed baseline ingredient
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("flour", 2, "All-Purpose Flour", "active", "[]", "[]", "{}");
|
||||
|
||||
// Seed baseline recipe
|
||||
db.prepare(
|
||||
"INSERT INTO recipes(id, schema_version, save_version, title, summary, categories_json, tags_json, yield_quantity, yield_unit_id, yield_servings, yield_basis, notes_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
"bread",
|
||||
2,
|
||||
1,
|
||||
"Country Bread",
|
||||
"Crusty artisan loaf",
|
||||
"[]",
|
||||
"[]",
|
||||
500,
|
||||
"gram",
|
||||
1,
|
||||
"measured",
|
||||
"[]",
|
||||
"{}"
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "comp_1", 1, "Dough", "[]");
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_items(recipe_id, component_id, id, position, ingredient_id, quantity, unit_id, basis_member, optional) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("bread", "comp_1", "line_1", 1, "flour", 500, "gram", 1, 0);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "step_1", 1, "Mix flour and water.", 0);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("database optimistic concurrency", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("updates recipe metadata when save_version matches", () => {
|
||||
const updated = saveRecipeMetadata(db, "bread", 1, {
|
||||
title: "Country Sourdough",
|
||||
summary: "Long-fermented loaf",
|
||||
categories_json: "[]",
|
||||
tags_json: "[]",
|
||||
yield_quantity: 600,
|
||||
yield_unit_id: "gram",
|
||||
yield_servings: 2,
|
||||
yield_basis: "measured",
|
||||
});
|
||||
|
||||
expect(updated.title).toBe("Country Sourdough");
|
||||
expect(updated.save_version).toBe(2);
|
||||
expect(updated.yield_quantity).toBe(600);
|
||||
});
|
||||
|
||||
it("rejects metadata updates when save_version is stale", () => {
|
||||
expect(() =>
|
||||
saveRecipeMetadata(db, "bread", 999, {
|
||||
title: "Conflict Sourdough",
|
||||
summary: null,
|
||||
categories_json: "[]",
|
||||
tags_json: "[]",
|
||||
yield_quantity: 500,
|
||||
yield_unit_id: "gram",
|
||||
yield_servings: 1,
|
||||
yield_basis: null,
|
||||
})
|
||||
).toThrow(/changed in another tab/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe structure saving & transactions", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("saves components, lines, steps, and new ingredients atomically", () => {
|
||||
const structure = saveRecipeStructure(db, "bread", {
|
||||
save_version: 1,
|
||||
new_ingredients: [{ id: "water", name: "Filtered Water" }],
|
||||
components: [
|
||||
{
|
||||
id: "comp_1",
|
||||
name: "Main Dough",
|
||||
items: [
|
||||
{
|
||||
id: "line_1",
|
||||
ingredient_id: "flour",
|
||||
quantity: 400,
|
||||
unit_id: "gram",
|
||||
basis_member: true,
|
||||
optional: false,
|
||||
prep: [],
|
||||
},
|
||||
{
|
||||
id: "line_2",
|
||||
ingredient_id: "water",
|
||||
quantity: 300,
|
||||
unit_id: "gram",
|
||||
basis_member: false,
|
||||
optional: false,
|
||||
prep: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: "step_1",
|
||||
instruction: "Combine flour and water.",
|
||||
equipment_ids: [],
|
||||
},
|
||||
{
|
||||
id: "step_2",
|
||||
instruction: "Bake at 450F.",
|
||||
equipment_ids: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(structure.save_version).toBe(2);
|
||||
expect(structure.components[0].items).toHaveLength(2);
|
||||
expect(structure.steps).toHaveLength(2);
|
||||
|
||||
// Verify new ingredient was created
|
||||
const water = db.prepare("SELECT * FROM ingredients WHERE id = ?").get("water") as any;
|
||||
expect(water).toBeDefined();
|
||||
expect(water.name).toBe("Filtered Water");
|
||||
});
|
||||
|
||||
it("rejects save without components or steps", () => {
|
||||
expect(() =>
|
||||
saveRecipeStructure(db, "bread", {
|
||||
save_version: 1,
|
||||
components: [],
|
||||
steps: [{ id: "step_1", instruction: "Mix.", equipment_ids: [] }],
|
||||
})
|
||||
).toThrow(/at least one component/);
|
||||
|
||||
expect(() =>
|
||||
saveRecipeStructure(db, "bread", {
|
||||
save_version: 1,
|
||||
components: [{ id: "c1", name: "Dough", items: [{ id: "l1", ingredient_id: "flour", quantity: 100, unit_id: "gram", basis_member: false, optional: false, prep: [] }] }],
|
||||
steps: [],
|
||||
})
|
||||
).toThrow(/at least one preparation step/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe duplication", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("creates a deep clone with a unique ID and duplicate title", () => {
|
||||
const copyId = duplicateRecipe(db, "bread");
|
||||
expect(copyId).toBe("bread_copy");
|
||||
|
||||
const copy = editableRecipe(db, copyId);
|
||||
expect(copy).toBeDefined();
|
||||
expect(copy?.title).toBe("Country Bread Copy");
|
||||
expect(copy?.save_version).toBe(1);
|
||||
|
||||
const copyStruct = recipeStructure(db, copyId);
|
||||
expect(copyStruct?.components).toHaveLength(1);
|
||||
expect(copyStruct?.steps).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipe quality metrics", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("identifies placeholder steps and unpriced items", () => {
|
||||
// Add a placeholder step
|
||||
db.prepare(
|
||||
"INSERT INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("bread", "step_todo", 2, "TODO: define baking temperature", 0);
|
||||
|
||||
const rows = recipeQualityRows(db);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].placeholder_steps).toBe(1);
|
||||
expect(rows[0].unpriced_items).toBe(1); // flour has no purchase items yet
|
||||
expect(rows[0].total_items).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive lifecycle & safe hard deletion", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("restores soft-deleted items across multiple entity types", () => {
|
||||
// Soft delete bread recipe and flour ingredient
|
||||
db.prepare("UPDATE recipes SET deleted_at = '2026-08-01' WHERE id = 'bread'").run();
|
||||
db.prepare("UPDATE ingredients SET deleted_at = '2026-08-01', status = 'archived' WHERE id = 'flour'").run();
|
||||
|
||||
restoreArchivedItems(db, [
|
||||
{ id: "bread", type: "recipe" },
|
||||
{ id: "flour", type: "ingredient" },
|
||||
]);
|
||||
|
||||
const bread = db.prepare("SELECT deleted_at FROM recipes WHERE id = 'bread'").get() as any;
|
||||
const flour = db.prepare("SELECT deleted_at, status FROM ingredients WHERE id = 'flour'").get() as any;
|
||||
|
||||
expect(bread.deleted_at).toBeNull();
|
||||
expect(flour.deleted_at).toBeNull();
|
||||
expect(flour.status).toBe("active");
|
||||
});
|
||||
|
||||
it("blocks permanent deletion of ingredients used in active recipes", () => {
|
||||
// Flour is used in active bread recipe
|
||||
expect(() =>
|
||||
permanentlyDeleteArchivedItems(db, [{ id: "flour", type: "ingredient" }])
|
||||
).toThrow(/currently used in active recipe/);
|
||||
});
|
||||
|
||||
it("permanently purges items when no active dependencies exist", () => {
|
||||
// Create an unreferenced ingredient
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("salt", 2, "Kosher Salt", "archived", "[]", "[]", "{}", "2026-08-01");
|
||||
|
||||
permanentlyDeleteArchivedItems(db, [{ id: "salt", type: "ingredient" }]);
|
||||
|
||||
const salt = db.prepare("SELECT 1 FROM ingredients WHERE id = 'salt'").get();
|
||||
expect(salt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,3 +180,96 @@ export function recipeQualityRows(database: DatabaseSync): QualityRecipe[] {
|
||||
ORDER BY r.title
|
||||
`).all() as unknown as QualityRecipe[];
|
||||
}
|
||||
|
||||
export function restoreArchivedItems(
|
||||
database: DatabaseSync,
|
||||
items: Array<{ id: string; type: string }>
|
||||
) {
|
||||
const tables: Record<string, string> = {
|
||||
recipe: "recipes",
|
||||
ingredient: "ingredients",
|
||||
book: "collections",
|
||||
purchase: "purchase_items",
|
||||
};
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const item of items) {
|
||||
const table = tables[item.type];
|
||||
if (table) {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE ${table} SET deleted_at = NULL${
|
||||
item.type === "ingredient" ? ", status = 'active'" : ""
|
||||
} WHERE id = ?`
|
||||
)
|
||||
.run(item.id);
|
||||
}
|
||||
}
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
refreshSiteProjection(database);
|
||||
}
|
||||
|
||||
export function permanentlyDeleteArchivedItems(
|
||||
database: DatabaseSync,
|
||||
items: Array<{ id: string; type: string }>
|
||||
) {
|
||||
// Check dependencies first
|
||||
for (const item of items) {
|
||||
if (item.type === "ingredient") {
|
||||
const activeRecipe = database
|
||||
.prepare(
|
||||
`SELECT r.title FROM recipe_items ri
|
||||
JOIN recipes r ON r.id = ri.recipe_id
|
||||
WHERE ri.ingredient_id = ? AND r.deleted_at IS NULL
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(item.id) as { title: string } | undefined;
|
||||
if (activeRecipe) {
|
||||
throw new Error(
|
||||
`Cannot delete ingredient '${item.id}' because it is currently used in active recipe '${activeRecipe.title}'. Remove it from the recipe first.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (item.type === "recipe") {
|
||||
const parentRecipe = database
|
||||
.prepare(
|
||||
`SELECT r.title FROM recipe_items ri
|
||||
JOIN recipes r ON r.id = ri.recipe_id
|
||||
WHERE ri.subrecipe_id = ? AND r.deleted_at IS NULL
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(item.id) as { title: string } | undefined;
|
||||
if (parentRecipe) {
|
||||
throw new Error(
|
||||
`Cannot delete recipe '${item.id}' because it is used as a sub-recipe in active recipe '${parentRecipe.title}'.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tables: Record<string, string> = {
|
||||
recipe: "recipes",
|
||||
ingredient: "ingredients",
|
||||
book: "collections",
|
||||
purchase: "purchase_items",
|
||||
};
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const item of items) {
|
||||
const table = tables[item.type];
|
||||
if (table) {
|
||||
database.prepare(`DELETE FROM ${table} WHERE id = ?`).run(item.id);
|
||||
}
|
||||
}
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
refreshSiteProjection(database);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import {
|
||||
createInventoryCount,
|
||||
getInventoryCountDetail,
|
||||
getInventoryCounts,
|
||||
getInventoryLocations,
|
||||
saveInventoryCountItems,
|
||||
} from "./repository/inventory-repository";
|
||||
|
||||
function createTestDatabase(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const schemaPath = path.resolve(process.cwd(), "migrations", "001_initial.sql");
|
||||
db.exec(fs.readFileSync(schemaPath, "utf8"));
|
||||
const invSchemaPath = path.resolve(process.cwd(), "migrations", "003_inventory.sql");
|
||||
db.exec(fs.readFileSync(invSchemaPath, "utf8"));
|
||||
|
||||
// Seed baseline units
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"gram", "Gram", "g", "mass", "metric", null, null, null
|
||||
);
|
||||
db.prepare("INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(
|
||||
"each", "Each", "ea", "count", "customary", null, null, null
|
||||
);
|
||||
|
||||
// Seed ingredients
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("butter", 2, "Unsalted Butter", "active", "[]", "[]", "{}");
|
||||
db.prepare(
|
||||
"INSERT INTO ingredients(id, schema_version, name, status, categories_json, tags_json, source_json) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("flour", 2, "Bread Flour", "active", "[]", "[]", "{}");
|
||||
|
||||
// Seed purchase item for butter: $5.00 for 500g ($0.01/g)
|
||||
db.prepare(
|
||||
"INSERT INTO purchase_items(id, ingredient_id, name, status, package_quantity, package_unit_id, units_per_case, usable_yield_factor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("pi_butter", "butter", "Butter 500g", "active", 500, "gram", 1, 1);
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO price_observations(purchase_item_id, effective_at, currency, amount, source_json) VALUES (?, ?, ?, ?, ?)"
|
||||
).run("pi_butter", "2026-08-01", "USD", 5.0, "{}");
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("inventory repository", () => {
|
||||
let db: DatabaseSync;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
});
|
||||
|
||||
it("fetches seeded inventory locations", () => {
|
||||
const locations = getInventoryLocations(db);
|
||||
expect(locations.length).toBeGreaterThanOrEqual(4);
|
||||
expect(locations.map((l) => l.name)).toContain("Walk-in Cooler");
|
||||
expect(locations.map((l) => l.name)).toContain("Dry Storage");
|
||||
});
|
||||
|
||||
it("creates a count session with pre-populated active ingredients", () => {
|
||||
const countId = createInventoryCount(db, {
|
||||
title: "End of Month Count - August 2026",
|
||||
counted_at: "2026-08-31",
|
||||
notes: "Routine inventory count",
|
||||
prepopulate: true,
|
||||
});
|
||||
|
||||
expect(countId).toBeDefined();
|
||||
|
||||
const detail = getInventoryCountDetail(db, countId);
|
||||
expect(detail).toBeDefined();
|
||||
expect(detail?.title).toBe("End of Month Count - August 2026");
|
||||
expect(detail?.status).toBe("open");
|
||||
expect(detail?.items).toHaveLength(2); // butter and flour
|
||||
});
|
||||
|
||||
it("saves count quantities, calculates extended valuation, and marks completed", () => {
|
||||
const countId = createInventoryCount(db, {
|
||||
title: "Weekly Count",
|
||||
counted_at: "2026-08-17",
|
||||
prepopulate: true,
|
||||
});
|
||||
|
||||
const locations = getInventoryLocations(db);
|
||||
const walkIn = locations.find((l) => l.name === "Walk-in Cooler")!;
|
||||
const dryStorage = locations.find((l) => l.name === "Dry Storage")!;
|
||||
|
||||
// Count 1000g of butter ($0.01/g = $10.00) in walk-in, and 2000g of flour (unpriced = $0.00) in dry storage
|
||||
saveInventoryCountItems(
|
||||
db,
|
||||
countId,
|
||||
[
|
||||
{
|
||||
location_id: walkIn.id,
|
||||
ingredient_id: "butter",
|
||||
quantity: 1000,
|
||||
unit_id: "gram",
|
||||
},
|
||||
{
|
||||
location_id: dryStorage.id,
|
||||
ingredient_id: "flour",
|
||||
quantity: 2000,
|
||||
unit_id: "gram",
|
||||
},
|
||||
],
|
||||
"completed"
|
||||
);
|
||||
|
||||
const detail = getInventoryCountDetail(db, countId);
|
||||
expect(detail?.status).toBe("completed");
|
||||
expect(detail?.total_value).toBe(10.0);
|
||||
|
||||
const counts = getInventoryCounts(db);
|
||||
expect(counts).toHaveLength(1);
|
||||
expect(counts[0].status).toBe("completed");
|
||||
expect(counts[0].total_value).toBe(10.0);
|
||||
expect(counts[0].item_count).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ingredients, units } from "./data";
|
||||
import { loadCatalogs } from "./data";
|
||||
import { convert, convertWithDensity, convertWithIngredientMeasures, densityInGramsPerMilliliter } from "./measurement";
|
||||
|
||||
const { ingredients, units } = loadCatalogs();
|
||||
|
||||
describe("normalized measurement conversion", () => {
|
||||
it("converts mass through canonical grams", () => {
|
||||
expect(convert(1, "pound", "ounce_mass", units)).toBeCloseTo(16, 8);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./recipe-repository";
|
||||
export * from "./ingredient-repository";
|
||||
export * from "./inventory-repository";
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
DensityMeasurement,
|
||||
Ingredient,
|
||||
MeasureConversion,
|
||||
SourceReference,
|
||||
} from "../types";
|
||||
import type { IngredientRow } from "./types";
|
||||
import { titleCase } from "../format";
|
||||
|
||||
const defaultSource: SourceReference = {
|
||||
source_type: "manual",
|
||||
title: "Recipe application",
|
||||
reviewed: true,
|
||||
};
|
||||
|
||||
const safeJson = <T>(value: string | null | undefined, fallback: T): T => {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export function getDomainIngredient(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): Ingredient | undefined {
|
||||
const row = database
|
||||
.prepare("SELECT * FROM ingredients WHERE id = ?")
|
||||
.get(id) as unknown as IngredientRow | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
const aliases = database
|
||||
.prepare("SELECT name, kind FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name")
|
||||
.all(id) as Array<{ name: string; kind?: string }>;
|
||||
|
||||
const densities = (
|
||||
database
|
||||
.prepare("SELECT * FROM ingredient_density_measurements WHERE ingredient_id = ? ORDER BY id")
|
||||
.all(id) as any[]
|
||||
).map((value): DensityMeasurement => ({
|
||||
id: value.id,
|
||||
mass: { quantity: value.mass_quantity, unit_id: value.mass_unit_id },
|
||||
volume: { quantity: value.volume_quantity, unit_id: value.volume_unit_id },
|
||||
...(value.temperature_c != null ? { temperature_c: value.temperature_c } : {}),
|
||||
...(value.state ? { state: value.state } : {}),
|
||||
source: safeJson<SourceReference>(value.source_json, defaultSource),
|
||||
}));
|
||||
|
||||
const conversions = (
|
||||
database
|
||||
.prepare("SELECT * FROM ingredient_measure_conversions WHERE ingredient_id = ? ORDER BY id")
|
||||
.all(id) as any[]
|
||||
).map((value): MeasureConversion => ({
|
||||
id: value.id,
|
||||
from: { quantity: value.from_quantity, unit_id: value.from_unit_id },
|
||||
to: { quantity: value.to_quantity, unit_id: value.to_unit_id },
|
||||
...(value.state ? { state: value.state } : {}),
|
||||
source: safeJson<SourceReference>(value.source_json, defaultSource),
|
||||
}));
|
||||
|
||||
const prep = (
|
||||
database
|
||||
.prepare("SELECT action_id, yield_factor, notes FROM ingredient_prep_actions WHERE ingredient_id = ? ORDER BY action_id")
|
||||
.all(id) as any[]
|
||||
).map((value) => ({
|
||||
action_id: value.action_id,
|
||||
yield_factor: value.yield_factor,
|
||||
...(value.notes ? { notes: value.notes } : {}),
|
||||
}));
|
||||
|
||||
const mappings = database
|
||||
.prepare("SELECT id, mapping_type FROM source_mappings WHERE subject_type = 'ingredient' AND subject_id = ? AND status = 'reviewed' ORDER BY id")
|
||||
.all(id) as Array<{ id: string; mapping_type: string }>;
|
||||
|
||||
const source = safeJson<Record<string, any>>(row.source_json, {});
|
||||
|
||||
return {
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
name: titleCase(row.name),
|
||||
...(row.description ? { description: row.description } : {}),
|
||||
status: row.status as Ingredient["status"],
|
||||
categories: safeJson<string[]>(row.categories_json, []),
|
||||
tags: safeJson<string[]>(row.tags_json, []),
|
||||
aliases,
|
||||
density_measurements: densities,
|
||||
measure_conversions: conversions,
|
||||
prep_actions: prep,
|
||||
nutrition_mapping_ids: mappings
|
||||
.filter((v) => v.mapping_type === "nutrition")
|
||||
.map((v) => v.id),
|
||||
allergen_mapping_ids: mappings
|
||||
.filter((v) => v.mapping_type === "allergen")
|
||||
.map((v) => v.id),
|
||||
...(source.shelf_life ? { shelf_life: source.shelf_life } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export type InventoryLocation = {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type InventoryCountSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: "open" | "completed";
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
item_count: number;
|
||||
total_value: number;
|
||||
};
|
||||
|
||||
export type InventoryCountItem = {
|
||||
count_id: string;
|
||||
location_id: string | null;
|
||||
location_name: string | null;
|
||||
ingredient_id: string;
|
||||
ingredient_name: string;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
unit_symbol: string;
|
||||
unit_cost: number | null;
|
||||
extended_cost: number | null;
|
||||
};
|
||||
|
||||
export type InventoryCountDetail = {
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: "open" | "completed";
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
total_value: number;
|
||||
locations: InventoryLocation[];
|
||||
items: InventoryCountItem[];
|
||||
};
|
||||
|
||||
export function getInventoryLocations(database: DatabaseSync): InventoryLocation[] {
|
||||
return database
|
||||
.prepare(
|
||||
"SELECT id, name, position FROM inventory_locations WHERE deleted_at IS NULL ORDER BY position, name"
|
||||
)
|
||||
.all() as unknown as InventoryLocation[];
|
||||
}
|
||||
|
||||
export function getInventoryCounts(database: DatabaseSync): InventoryCountSummary[] {
|
||||
const counts = database
|
||||
.prepare(
|
||||
`SELECT id, title, counted_at, status, notes, created_at
|
||||
FROM inventory_counts
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY counted_at DESC, created_at DESC`
|
||||
)
|
||||
.all() as unknown as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: "open" | "completed";
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
}>;
|
||||
|
||||
return counts.map((count) => {
|
||||
const stats = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as item_count,
|
||||
COALESCE(SUM(extended_cost), 0) as total_value
|
||||
FROM inventory_count_items
|
||||
WHERE count_id = ?`
|
||||
)
|
||||
.get(count.id) as { item_count: number; total_value: number } | undefined;
|
||||
|
||||
return {
|
||||
...count,
|
||||
item_count: stats?.item_count ?? 0,
|
||||
total_value: Math.round((stats?.total_value ?? 0) * 100) / 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getInventoryCountDetail(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): InventoryCountDetail | undefined {
|
||||
const count = database
|
||||
.prepare(
|
||||
"SELECT id, title, counted_at, status, notes, created_at FROM inventory_counts WHERE id = ? AND deleted_at IS NULL"
|
||||
)
|
||||
.get(id) as unknown as
|
||||
| {
|
||||
id: string;
|
||||
title: string;
|
||||
counted_at: string;
|
||||
status: "open" | "completed";
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!count) return undefined;
|
||||
|
||||
const locations = getInventoryLocations(database);
|
||||
|
||||
const rawItems = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
ci.count_id,
|
||||
ci.location_id,
|
||||
loc.name as location_name,
|
||||
ci.ingredient_id,
|
||||
ing.name as ingredient_name,
|
||||
ci.quantity,
|
||||
ci.unit_id,
|
||||
u.symbol as unit_symbol,
|
||||
ci.unit_cost,
|
||||
ci.extended_cost
|
||||
FROM inventory_count_items ci
|
||||
JOIN ingredients ing ON ing.id = ci.ingredient_id
|
||||
LEFT JOIN inventory_locations loc ON loc.id = ci.location_id
|
||||
LEFT JOIN units u ON u.id = ci.unit_id
|
||||
WHERE ci.count_id = ?
|
||||
ORDER BY COALESCE(loc.position, 999), ing.name`
|
||||
)
|
||||
.all(id) as unknown as Array<{
|
||||
count_id: string;
|
||||
location_id: string | null;
|
||||
location_name: string | null;
|
||||
ingredient_id: string;
|
||||
ingredient_name: string;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
unit_symbol: string | null;
|
||||
unit_cost: number | null;
|
||||
extended_cost: number | null;
|
||||
}>;
|
||||
|
||||
const items: InventoryCountItem[] = rawItems.map((row) => ({
|
||||
...row,
|
||||
unit_symbol: row.unit_symbol || row.unit_id,
|
||||
}));
|
||||
|
||||
const total_value = items.reduce(
|
||||
(sum, item) => sum + (item.extended_cost ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
...count,
|
||||
total_value: Math.round(total_value * 100) / 100,
|
||||
locations,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInventoryCount(
|
||||
database: DatabaseSync,
|
||||
data: {
|
||||
title: string;
|
||||
counted_at: string;
|
||||
notes?: string;
|
||||
prepopulate?: boolean;
|
||||
}
|
||||
): string {
|
||||
const base = `count_${data.counted_at.replace(/[^0-9]/g, "")}`;
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (database.prepare("SELECT 1 FROM inventory_counts WHERE id = ?").get(id)) {
|
||||
id = `${base}_${suffix++}`;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
database
|
||||
.prepare(
|
||||
"INSERT INTO inventory_counts (id, title, counted_at, status, notes, created_at) VALUES (?, ?, ?, 'open', ?, ?)"
|
||||
)
|
||||
.run(id, data.title.trim(), data.counted_at, data.notes?.trim() || null, now);
|
||||
|
||||
if (data.prepopulate !== false) {
|
||||
// Prepopulate active ingredients with zero quantities
|
||||
const defaultLocation = database
|
||||
.prepare("SELECT id FROM inventory_locations WHERE deleted_at IS NULL ORDER BY position LIMIT 1")
|
||||
.get() as { id: string } | undefined;
|
||||
|
||||
const ingredients = database
|
||||
.prepare(
|
||||
`SELECT i.id, i.name
|
||||
FROM ingredients i
|
||||
WHERE i.status = 'active' AND i.deleted_at IS NULL
|
||||
ORDER BY i.name`
|
||||
)
|
||||
.all() as Array<{ id: string; name: string }>;
|
||||
|
||||
const insertItem = database.prepare(
|
||||
`INSERT OR IGNORE INTO inventory_count_items
|
||||
(count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost)
|
||||
VALUES (?, ?, ?, 0, 'gram', 0, 0)`
|
||||
);
|
||||
|
||||
for (const ingredient of ingredients) {
|
||||
insertItem.run(id, defaultLocation?.id ?? null, ingredient.id);
|
||||
}
|
||||
}
|
||||
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function saveInventoryCountItems(
|
||||
database: DatabaseSync,
|
||||
countId: string,
|
||||
items: Array<{
|
||||
location_id: string | null;
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
}>,
|
||||
status?: "open" | "completed"
|
||||
) {
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
// Delete existing items and rebuild cleanly
|
||||
database.prepare("DELETE FROM inventory_count_items WHERE count_id = ?").run(countId);
|
||||
|
||||
const insert = database.prepare(
|
||||
`INSERT INTO inventory_count_items (count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
// Look up current purchase unit cost if available
|
||||
const priceRow = database
|
||||
.prepare(
|
||||
`SELECT (po.amount / (p.package_quantity * p.units_per_case)) as cost_per_unit
|
||||
FROM purchase_items p
|
||||
JOIN price_observations po ON po.purchase_item_id = p.id
|
||||
WHERE p.ingredient_id = ? AND p.status = 'active'
|
||||
ORDER BY po.effective_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(item.ingredient_id) as { cost_per_unit: number } | undefined;
|
||||
|
||||
const unit_cost = priceRow?.cost_per_unit ?? null;
|
||||
const extended_cost =
|
||||
unit_cost != null && Number.isFinite(item.quantity)
|
||||
? Math.round(item.quantity * unit_cost * 100) / 100
|
||||
: 0;
|
||||
|
||||
insert.run(
|
||||
countId,
|
||||
item.location_id,
|
||||
item.ingredient_id,
|
||||
item.quantity,
|
||||
item.unit_id,
|
||||
unit_cost,
|
||||
extended_cost
|
||||
);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
database
|
||||
.prepare("UPDATE inventory_counts SET status = ? WHERE id = ?")
|
||||
.run(status, countId);
|
||||
}
|
||||
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
DensityMeasurement,
|
||||
Ingredient,
|
||||
MeasureConversion,
|
||||
PrepAction,
|
||||
PurchaseItem,
|
||||
Recipe,
|
||||
SourceMapping,
|
||||
SourceReference,
|
||||
Unit,
|
||||
} from "../types";
|
||||
import type {
|
||||
IngredientRow,
|
||||
ItemPrepActionRow,
|
||||
PriceObservationRow,
|
||||
PurchaseItemRow,
|
||||
RecipeCalculationContext,
|
||||
RecipeComponentRow,
|
||||
RecipeItemRow,
|
||||
RecipeMeasureConversionRow,
|
||||
RecipeRow,
|
||||
RecipeStepRow,
|
||||
SourceMappingRow,
|
||||
UnitRow,
|
||||
} from "./types";
|
||||
import { titleCase } from "../format";
|
||||
|
||||
const defaultSource: SourceReference = {
|
||||
source_type: "manual",
|
||||
title: "Recipe application",
|
||||
reviewed: true,
|
||||
};
|
||||
|
||||
const safeJson = <T>(value: string | null | undefined, fallback: T): T => {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export type EditableRecipe = {
|
||||
id: string;
|
||||
save_version: number;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
yield_servings: number | null;
|
||||
yield_basis: string | null;
|
||||
source_json: string;
|
||||
};
|
||||
|
||||
export function getEditableRecipe(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): EditableRecipe | undefined {
|
||||
return database
|
||||
.prepare(
|
||||
"SELECT id, save_version, title, summary, categories_json, tags_json, yield_quantity, yield_unit_id, yield_servings, yield_basis, source_json FROM recipes WHERE id = ?"
|
||||
)
|
||||
.get(id) as unknown as EditableRecipe | undefined;
|
||||
}
|
||||
|
||||
export function buildDomainUnits(database: DatabaseSync): Map<string, Unit> {
|
||||
const rows = database
|
||||
.prepare("SELECT * FROM units ORDER BY id")
|
||||
.all() as unknown as UnitRow[];
|
||||
const map = new Map<string, Unit>();
|
||||
for (const row of rows) {
|
||||
map.set(row.id, {
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
symbol: row.symbol,
|
||||
dimension: row.dimension as Unit["dimension"],
|
||||
system: row.system as Unit["system"],
|
||||
...(row.base_unit_id
|
||||
? {
|
||||
base_conversion: {
|
||||
base_unit_id: row.base_unit_id,
|
||||
factor: row.factor ?? 1,
|
||||
...(row.offset != null ? { offset: row.offset } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function buildDomainPrepActions(database: DatabaseSync): Map<string, PrepAction> {
|
||||
const rows = database
|
||||
.prepare("SELECT * FROM prep_actions ORDER BY id")
|
||||
.all() as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
action_type: string;
|
||||
default_yield_factor: number | null;
|
||||
notes: string | null;
|
||||
}>;
|
||||
const map = new Map<string, PrepAction>();
|
||||
for (const row of rows) {
|
||||
map.set(row.id, {
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
action_type: row.action_type as PrepAction["action_type"],
|
||||
...(row.default_yield_factor != null
|
||||
? { default_yield_factor: row.default_yield_factor }
|
||||
: {}),
|
||||
...(row.notes ? { notes: row.notes } : {}),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getDomainRecipe(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): Recipe | undefined {
|
||||
const row = database
|
||||
.prepare("SELECT * FROM recipes WHERE id = ?")
|
||||
.get(id) as unknown as RecipeRow | undefined;
|
||||
if (!row) return undefined;
|
||||
|
||||
const componentRows = database
|
||||
.prepare("SELECT * FROM recipe_components WHERE recipe_id = ? ORDER BY position")
|
||||
.all(id) as unknown as RecipeComponentRow[];
|
||||
const itemRows = database
|
||||
.prepare("SELECT * FROM recipe_items WHERE recipe_id = ? ORDER BY position")
|
||||
.all(id) as unknown as RecipeItemRow[];
|
||||
const prepRows = database
|
||||
.prepare("SELECT * FROM item_prep_actions WHERE recipe_id = ? ORDER BY position")
|
||||
.all(id) as unknown as ItemPrepActionRow[];
|
||||
const stepRows = database
|
||||
.prepare("SELECT * FROM recipe_steps WHERE recipe_id = ? ORDER BY position")
|
||||
.all(id) as unknown as RecipeStepRow[];
|
||||
const stepEquipmentRows = database
|
||||
.prepare("SELECT step_id, equipment_id FROM step_equipment WHERE recipe_id = ? ORDER BY equipment_id")
|
||||
.all(id) as Array<{ step_id: string; equipment_id: string }>;
|
||||
const recipeEquipmentRows = database
|
||||
.prepare("SELECT equipment_id FROM recipe_equipment WHERE recipe_id = ? ORDER BY equipment_id")
|
||||
.all(id) as Array<{ equipment_id: string }>;
|
||||
const conversionRows = database
|
||||
.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id = ? ORDER BY id")
|
||||
.all(id) as unknown as RecipeMeasureConversionRow[];
|
||||
|
||||
const source = safeJson<Record<string, any>>(row.source_json, {});
|
||||
const oldComponents = new Map(
|
||||
(source.components ?? []).map((v: any) => [v.id, v])
|
||||
);
|
||||
const oldSteps = new Map((source.steps ?? []).map((v: any) => [v.id, v]));
|
||||
|
||||
const prepByItem = new Map<string, ItemPrepActionRow[]>();
|
||||
for (const prep of prepRows) {
|
||||
const list = prepByItem.get(prep.item_id) ?? [];
|
||||
list.push(prep);
|
||||
prepByItem.set(prep.item_id, list);
|
||||
}
|
||||
|
||||
const itemsByComponent = new Map<string, RecipeItemRow[]>();
|
||||
for (const item of itemRows) {
|
||||
const list = itemsByComponent.get(item.component_id) ?? [];
|
||||
list.push(item);
|
||||
itemsByComponent.set(item.component_id, list);
|
||||
}
|
||||
|
||||
const equipmentByStep = new Map<string, string[]>();
|
||||
for (const eq of stepEquipmentRows) {
|
||||
const list = equipmentByStep.get(eq.step_id) ?? [];
|
||||
list.push(eq.equipment_id);
|
||||
equipmentByStep.set(eq.step_id, list);
|
||||
}
|
||||
|
||||
const components = componentRows.map((comp) => {
|
||||
const old = oldComponents.get(comp.id) ?? {};
|
||||
const compItems = (itemsByComponent.get(comp.id) ?? []).map((item) => {
|
||||
const itemPrep = (prepByItem.get(item.id) ?? []).map((prep) => ({
|
||||
action_id: prep.action_id,
|
||||
...(prep.yield_factor != null ? { yield_factor: prep.yield_factor } : {}),
|
||||
...(prep.notes ? { notes: prep.notes } : {}),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
reference: item.ingredient_id
|
||||
? { ingredient_id: item.ingredient_id }
|
||||
: { recipe_id: item.subrecipe_id! },
|
||||
amount: {
|
||||
quantity: item.quantity,
|
||||
unit_id: item.unit_id,
|
||||
},
|
||||
...(item.percentage != null ? { percentage: item.percentage } : {}),
|
||||
...(item.basis_member ? { basis_member: true as const } : {}),
|
||||
...(item.optional ? { optional: true as const } : {}),
|
||||
...(item.notes ? { notes: item.notes } : {}),
|
||||
...(item.nutrition_retention_factor !== 1
|
||||
? { nutrition_retention_factor: item.nutrition_retention_factor }
|
||||
: {}),
|
||||
...(itemPrep.length ? { prep: itemPrep } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...old,
|
||||
id: comp.id,
|
||||
name: comp.name,
|
||||
notes: safeJson<string[]>(comp.notes_json, []),
|
||||
items: compItems,
|
||||
};
|
||||
});
|
||||
|
||||
const steps = stepRows.map((step, index) => {
|
||||
const old = oldSteps.get(step.id) ?? {};
|
||||
const eqIds = equipmentByStep.get(step.id) ?? [];
|
||||
return {
|
||||
...old,
|
||||
id: step.id,
|
||||
order: index + 1,
|
||||
instruction: step.instruction,
|
||||
...(step.critical_control_point ? { critical_control_point: true as const } : {}),
|
||||
...(eqIds.length ? { equipment_ids: eqIds } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const measureConversions = conversionRows.map((conv) => ({
|
||||
id: conv.id,
|
||||
from: { quantity: conv.from_quantity, unit_id: conv.from_unit_id },
|
||||
to: { quantity: conv.to_quantity, unit_id: conv.to_unit_id },
|
||||
...(conv.notes ? { notes: conv.notes } : {}),
|
||||
source: safeJson<SourceReference>(conv.source_json, defaultSource),
|
||||
}));
|
||||
|
||||
const scaling = row.scaling_mode
|
||||
? {
|
||||
mode: row.scaling_mode as NonNullable<Recipe["scaling"]>["mode"],
|
||||
...(row.scaling_basis_id ? { basis_id: row.scaling_basis_id } : {}),
|
||||
...(row.scaling_basis_quantity != null && row.scaling_basis_unit_id
|
||||
? {
|
||||
basis_amount: {
|
||||
quantity: row.scaling_basis_quantity,
|
||||
unit_id: row.scaling_basis_unit_id,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const equipmentIds = recipeEquipmentRows.map((e) => e.equipment_id);
|
||||
|
||||
return {
|
||||
...source,
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
...(row.summary ? { summary: row.summary } : {}),
|
||||
categories: safeJson<string[]>(row.categories_json, []),
|
||||
tags: safeJson<string[]>(row.tags_json, []),
|
||||
...(row.station ? { station: row.station } : {}),
|
||||
...(row.auto_yield ? { auto_yield: true as const } : {}),
|
||||
yield: {
|
||||
...(source.yield ?? {}),
|
||||
amount: { quantity: row.yield_quantity, unit_id: row.yield_unit_id },
|
||||
...(row.yield_servings != null ? { servings: row.yield_servings } : {}),
|
||||
...(row.yield_basis ? { basis: row.yield_basis as Recipe["yield"]["basis"] } : {}),
|
||||
},
|
||||
...(scaling ? { scaling } : {}),
|
||||
components,
|
||||
steps,
|
||||
notes: safeJson<string[]>(row.notes_json, []),
|
||||
measure_conversions: measureConversions,
|
||||
...(equipmentIds.length ? { equipment_ids: equipmentIds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRecipeCalculationContext(
|
||||
database: DatabaseSync,
|
||||
id: string
|
||||
): RecipeCalculationContext | undefined {
|
||||
const domainRecipe = getDomainRecipe(database, id);
|
||||
if (!domainRecipe) return undefined;
|
||||
|
||||
const recipeMap = new Map<string, Recipe>([[domainRecipe.id, domainRecipe]]);
|
||||
const ingredientIds = new Set<string>();
|
||||
const subrecipeIdsToVisit = new Set<string>();
|
||||
|
||||
const collectReferences = (rec: Recipe) => {
|
||||
for (const comp of rec.components) {
|
||||
for (const item of comp.items) {
|
||||
if ("ingredient_id" in item.reference) {
|
||||
ingredientIds.add(item.reference.ingredient_id);
|
||||
} else if ("recipe_id" in item.reference) {
|
||||
if (!recipeMap.has(item.reference.recipe_id)) {
|
||||
subrecipeIdsToVisit.add(item.reference.recipe_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collectReferences(domainRecipe);
|
||||
|
||||
// Recursively collect all referenced subrecipes
|
||||
while (subrecipeIdsToVisit.size > 0) {
|
||||
const nextId = subrecipeIdsToVisit.values().next().value!;
|
||||
subrecipeIdsToVisit.delete(nextId);
|
||||
if (!recipeMap.has(nextId)) {
|
||||
const sub = getDomainRecipe(database, nextId);
|
||||
if (sub) {
|
||||
recipeMap.set(sub.id, sub);
|
||||
collectReferences(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ingredientMap = new Map<string, Ingredient>();
|
||||
if (ingredientIds.size > 0) {
|
||||
const placeholders = Array.from(ingredientIds).map(() => "?").join(",");
|
||||
const ingRows = database
|
||||
.prepare(`SELECT * FROM ingredients WHERE id IN (${placeholders})`)
|
||||
.all(...Array.from(ingredientIds)) as unknown as IngredientRow[];
|
||||
|
||||
const aliasRows = database
|
||||
.prepare(`SELECT ingredient_id, name, kind FROM ingredient_aliases WHERE ingredient_id IN (${placeholders}) ORDER BY name`)
|
||||
.all(...Array.from(ingredientIds)) as Array<{ ingredient_id: string; name: string; kind: string | null }>;
|
||||
|
||||
const densityRows = database
|
||||
.prepare(`SELECT * FROM ingredient_density_measurements WHERE ingredient_id IN (${placeholders}) ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as Array<any>;
|
||||
|
||||
const convRows = database
|
||||
.prepare(`SELECT * FROM ingredient_measure_conversions WHERE ingredient_id IN (${placeholders}) ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as Array<any>;
|
||||
|
||||
const prepRows = database
|
||||
.prepare(`SELECT * FROM ingredient_prep_actions WHERE ingredient_id IN (${placeholders}) ORDER BY action_id`)
|
||||
.all(...Array.from(ingredientIds)) as Array<any>;
|
||||
|
||||
const mappingIdRows = database
|
||||
.prepare(`SELECT id, subject_id, mapping_type FROM source_mappings WHERE subject_type = 'ingredient' AND subject_id IN (${placeholders}) AND status = 'reviewed' ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as Array<{ id: string; subject_id: string; mapping_type: string }>;
|
||||
|
||||
const aliasesByIng = new Map<string, Array<{ name: string; kind?: string }>>();
|
||||
for (const a of aliasRows) {
|
||||
const list = aliasesByIng.get(a.ingredient_id) ?? [];
|
||||
list.push({ name: a.name, ...(a.kind ? { kind: a.kind } : {}) });
|
||||
aliasesByIng.set(a.ingredient_id, list);
|
||||
}
|
||||
|
||||
const densitiesByIng = new Map<string, DensityMeasurement[]>();
|
||||
for (const d of densityRows) {
|
||||
const list = densitiesByIng.get(d.ingredient_id) ?? [];
|
||||
list.push({
|
||||
id: d.id,
|
||||
mass: { quantity: d.mass_quantity, unit_id: d.mass_unit_id },
|
||||
volume: { quantity: d.volume_quantity, unit_id: d.volume_unit_id },
|
||||
...(d.temperature_c != null ? { temperature_c: d.temperature_c } : {}),
|
||||
...(d.state ? { state: d.state } : {}),
|
||||
source: safeJson<SourceReference>(d.source_json, defaultSource),
|
||||
});
|
||||
densitiesByIng.set(d.ingredient_id, list);
|
||||
}
|
||||
|
||||
const convsByIng = new Map<string, MeasureConversion[]>();
|
||||
for (const c of convRows) {
|
||||
const list = convsByIng.get(c.ingredient_id) ?? [];
|
||||
list.push({
|
||||
id: c.id,
|
||||
from: { quantity: c.from_quantity, unit_id: c.from_unit_id },
|
||||
to: { quantity: c.to_quantity, unit_id: c.to_unit_id },
|
||||
...(c.state ? { state: c.state } : {}),
|
||||
source: safeJson<SourceReference>(c.source_json, defaultSource),
|
||||
});
|
||||
convsByIng.set(c.ingredient_id, list);
|
||||
}
|
||||
|
||||
const prepByIng = new Map<string, any[]>();
|
||||
for (const p of prepRows) {
|
||||
const list = prepByIng.get(p.ingredient_id) ?? [];
|
||||
list.push({
|
||||
action_id: p.action_id,
|
||||
yield_factor: p.yield_factor,
|
||||
...(p.notes ? { notes: p.notes } : {}),
|
||||
});
|
||||
prepByIng.set(p.ingredient_id, list);
|
||||
}
|
||||
|
||||
const mappingsByIng = new Map<string, { nutrition: string[]; allergen: string[] }>();
|
||||
for (const m of mappingIdRows) {
|
||||
const entry = mappingsByIng.get(m.subject_id) ?? { nutrition: [], allergen: [] };
|
||||
if (m.mapping_type === "nutrition") entry.nutrition.push(m.id);
|
||||
if (m.mapping_type === "allergen") entry.allergen.push(m.id);
|
||||
mappingsByIng.set(m.subject_id, entry);
|
||||
}
|
||||
|
||||
for (const row of ingRows) {
|
||||
const source = safeJson<Record<string, any>>(row.source_json, {});
|
||||
const mapping = mappingsByIng.get(row.id) ?? { nutrition: [], allergen: [] };
|
||||
|
||||
ingredientMap.set(row.id, {
|
||||
schema_version: 2,
|
||||
id: row.id,
|
||||
name: titleCase(row.name),
|
||||
...(row.description ? { description: row.description } : {}),
|
||||
status: row.status as Ingredient["status"],
|
||||
categories: safeJson<string[]>(row.categories_json, []),
|
||||
tags: safeJson<string[]>(row.tags_json, []),
|
||||
aliases: aliasesByIng.get(row.id) ?? [],
|
||||
density_measurements: densitiesByIng.get(row.id) ?? [],
|
||||
measure_conversions: convsByIng.get(row.id) ?? [],
|
||||
prep_actions: prepByIng.get(row.id) ?? [],
|
||||
nutrition_mapping_ids: mapping.nutrition,
|
||||
allergen_mapping_ids: mapping.allergen,
|
||||
...(source.shelf_life ? { shelf_life: source.shelf_life } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch purchase items and prices for referenced ingredients
|
||||
const purchaseItemsMap = new Map<string, PurchaseItem>();
|
||||
if (ingredientIds.size > 0) {
|
||||
const placeholders = Array.from(ingredientIds).map(() => "?").join(",");
|
||||
const purchaseRows = database
|
||||
.prepare(`SELECT * FROM purchase_items WHERE ingredient_id IN (${placeholders}) ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as unknown as PurchaseItemRow[];
|
||||
|
||||
if (purchaseRows.length > 0) {
|
||||
const purchaseIds = purchaseRows.map((p) => p.id);
|
||||
const pPlaceholders = purchaseIds.map(() => "?").join(",");
|
||||
const priceRows = database
|
||||
.prepare(`SELECT * FROM price_observations WHERE purchase_item_id IN (${pPlaceholders}) ORDER BY effective_at, currency`)
|
||||
.all(...purchaseIds) as unknown as PriceObservationRow[];
|
||||
|
||||
const pricesByPurchase = new Map<string, Array<{ amount: number; currency: string; effective_at: string; source: SourceReference }>>();
|
||||
for (const pr of priceRows) {
|
||||
const list = pricesByPurchase.get(pr.purchase_item_id) ?? [];
|
||||
list.push({
|
||||
amount: pr.amount,
|
||||
currency: pr.currency,
|
||||
effective_at: pr.effective_at,
|
||||
source: safeJson<SourceReference>(pr.source_json, defaultSource),
|
||||
});
|
||||
pricesByPurchase.set(pr.purchase_item_id, list);
|
||||
}
|
||||
|
||||
for (const p of purchaseRows) {
|
||||
purchaseItemsMap.set(p.id, {
|
||||
schema_version: 2,
|
||||
id: p.id,
|
||||
ingredient_id: p.ingredient_id,
|
||||
name: p.name,
|
||||
...(p.brand ? { brand: p.brand } : {}),
|
||||
...(p.supplier_id ? { supplier_id: p.supplier_id } : {}),
|
||||
...(p.supplier_sku ? { supplier_sku: p.supplier_sku } : {}),
|
||||
status: p.status as PurchaseItem["status"],
|
||||
package: {
|
||||
quantity: p.package_quantity,
|
||||
unit_id: p.package_unit_id,
|
||||
units_per_case: p.units_per_case,
|
||||
usable_yield_factor: p.usable_yield_factor,
|
||||
},
|
||||
prices: pricesByPurchase.get(p.id) ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch source mappings for referenced ingredients
|
||||
const sourceMappingsMap = new Map<string, SourceMapping>();
|
||||
if (ingredientIds.size > 0) {
|
||||
const placeholders = Array.from(ingredientIds).map(() => "?").join(",");
|
||||
const mappingRows = database
|
||||
.prepare(`SELECT * FROM source_mappings WHERE subject_type = 'ingredient' AND subject_id IN (${placeholders}) ORDER BY id`)
|
||||
.all(...Array.from(ingredientIds)) as unknown as SourceMappingRow[];
|
||||
|
||||
for (const m of mappingRows) {
|
||||
sourceMappingsMap.set(m.id, {
|
||||
schema_version: 2,
|
||||
id: m.id,
|
||||
subject: { type: m.subject_type as SourceMapping["subject"]["type"], id: m.subject_id },
|
||||
mapping_type: m.mapping_type as SourceMapping["mapping_type"],
|
||||
status: m.status as SourceMapping["status"],
|
||||
source: safeJson<SourceReference>(m.source_json, defaultSource),
|
||||
...(m.nutrition_json ? { nutrition_per_100g: safeJson(m.nutrition_json, {}) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const unitsMap = buildDomainUnits(database);
|
||||
const prepActionsMap = buildDomainPrepActions(database);
|
||||
|
||||
return {
|
||||
domainRecipe,
|
||||
recipes: recipeMap,
|
||||
ingredients: ingredientMap,
|
||||
units: unitsMap,
|
||||
purchaseItems: purchaseItemsMap,
|
||||
sourceMappings: sourceMappingsMap,
|
||||
prepActions: prepActionsMap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type {
|
||||
Ingredient,
|
||||
PrepAction,
|
||||
PurchaseItem,
|
||||
Recipe,
|
||||
SourceMapping,
|
||||
Unit,
|
||||
} from "../types";
|
||||
|
||||
export type UnitRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
dimension: string;
|
||||
system: string;
|
||||
base_unit_id: string | null;
|
||||
factor: number | null;
|
||||
offset: number | null;
|
||||
};
|
||||
|
||||
export type IngredientRow = {
|
||||
id: string;
|
||||
schema_version: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
source_json: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type RecipeRow = {
|
||||
id: string;
|
||||
schema_version: number;
|
||||
save_version: number;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
categories_json: string;
|
||||
tags_json: string;
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
yield_servings: number | null;
|
||||
yield_basis: string | null;
|
||||
scaling_mode: string | null;
|
||||
scaling_basis_id: string | null;
|
||||
scaling_basis_quantity: number | null;
|
||||
scaling_basis_unit_id: string | null;
|
||||
notes_json: string;
|
||||
source_json: string;
|
||||
auto_yield: number;
|
||||
station: string | null;
|
||||
cover_media_url: string | null;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type RecipeComponentRow = {
|
||||
recipe_id: string;
|
||||
id: string;
|
||||
position: number;
|
||||
name: string;
|
||||
notes_json: string;
|
||||
};
|
||||
|
||||
export type RecipeItemRow = {
|
||||
recipe_id: string;
|
||||
component_id: string;
|
||||
id: string;
|
||||
position: number;
|
||||
ingredient_id: string | null;
|
||||
subrecipe_id: string | null;
|
||||
quantity: number;
|
||||
unit_id: string;
|
||||
percentage: number | null;
|
||||
basis_member: number;
|
||||
optional: number;
|
||||
notes: string | null;
|
||||
nutrition_retention_factor: number;
|
||||
};
|
||||
|
||||
export type ItemPrepActionRow = {
|
||||
recipe_id: string;
|
||||
item_id: string;
|
||||
position: number;
|
||||
action_id: string;
|
||||
yield_factor: number | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type RecipeStepRow = {
|
||||
recipe_id: string;
|
||||
id: string;
|
||||
position: number;
|
||||
instruction: string;
|
||||
critical_control_point: number;
|
||||
};
|
||||
|
||||
export type RecipeMeasureConversionRow = {
|
||||
recipe_id: string;
|
||||
id: string;
|
||||
from_quantity: number;
|
||||
from_unit_id: string;
|
||||
to_quantity: number;
|
||||
to_unit_id: string;
|
||||
notes: string | null;
|
||||
source_json: string;
|
||||
};
|
||||
|
||||
export type PurchaseItemRow = {
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
supplier_id: string | null;
|
||||
supplier_sku: string | null;
|
||||
status: string;
|
||||
package_quantity: number;
|
||||
package_unit_id: string;
|
||||
units_per_case: number;
|
||||
usable_yield_factor: number;
|
||||
};
|
||||
|
||||
export type PriceObservationRow = {
|
||||
purchase_item_id: string;
|
||||
effective_at: string;
|
||||
currency: string;
|
||||
amount: number;
|
||||
source_json: string;
|
||||
};
|
||||
|
||||
export type SourceMappingRow = {
|
||||
id: string;
|
||||
subject_type: string;
|
||||
subject_id: string;
|
||||
mapping_type: string;
|
||||
status: string;
|
||||
source_json: string;
|
||||
nutrition_json: string | null;
|
||||
};
|
||||
|
||||
export type RecipeCalculationContext = {
|
||||
domainRecipe: Recipe;
|
||||
recipes: Map<string, Recipe>;
|
||||
ingredients: Map<string, Ingredient>;
|
||||
units: Map<string, Unit>;
|
||||
purchaseItems: Map<string, PurchaseItem>;
|
||||
sourceMappings: Map<string, SourceMapping>;
|
||||
prepActions: Map<string, PrepAction>;
|
||||
};
|
||||
|
||||
export type DirectoryRecipeRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
item_count: number;
|
||||
placeholder_count: number;
|
||||
};
|
||||
|
||||
export type DirectoryIngredientRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
recipe_count: number;
|
||||
price_count: number;
|
||||
nutrition_count: number;
|
||||
};
|
||||
|
||||
export type DirectoryBookRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
recipe_count: number;
|
||||
};
|
||||
|
||||
export type DirectoryPurchaseRow = {
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
name: string;
|
||||
supplier_id: string | null;
|
||||
status: string;
|
||||
ingredient_name: string;
|
||||
package_quantity: number;
|
||||
package_unit_id: string;
|
||||
latest_price: number | null;
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Light.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Book.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Medium.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Bold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'CircularCustCapNum';
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('/fonts/circular/CircularCustCapNum-Black.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--ink: #050841;
|
||||
--ink-dark: #050841;
|
||||
--ink-medium: #202962;
|
||||
--ink-secondary: #3C4679;
|
||||
--muted: #8283a0;
|
||||
--muted-light: #a5a9c1;
|
||||
--muted-subtle: #757677;
|
||||
--paper: #f3f3f3;
|
||||
--card: #ffffff;
|
||||
--card-alt: #fbfbfb;
|
||||
--card-paper: #f1f5fe;
|
||||
--line: #ececec;
|
||||
--line-subtle: #f3f3f3;
|
||||
--line-focus: #dadada;
|
||||
--blue: #3d5df6;
|
||||
--blue-hover: #1236e1;
|
||||
--blue-active: #001992;
|
||||
--blue-light: #647df8;
|
||||
--blue-tint: #f1f5fe;
|
||||
--blue-badge: #dbe4ff;
|
||||
--green: #40b49a;
|
||||
--green-dark: #20ab85;
|
||||
--green-tint: #abddd1;
|
||||
--orange: #f3a642;
|
||||
--red: #f63d48;
|
||||
--red-dark: #bc0020;
|
||||
--red-tint: #fff5f6;
|
||||
--purple: #6234f2;
|
||||
--pink-badge: #ffd2f5;
|
||||
--serif: "CircularCustCapNum", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--sans: "CircularCustCapNum", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body, button, input, select, textarea {
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body { margin: 0; min-height: 100vh; }
|
||||
a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: .2em; }
|
||||
.shell { width: min(1160px, calc(100% - 40px)); margin-inline: auto; }
|
||||
|
||||
/* Sticky Detail Utility Header */
|
||||
.detail-utility {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #ececec;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.detail-utility-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.detail-utility-home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: #050841;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.detail-utility-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #050841;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-utility-divider {
|
||||
color: #a5a9c1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-utility-crumb {
|
||||
color: #050841;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.detail-utility-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.detail-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fbfbfb;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
width: 360px;
|
||||
}
|
||||
.detail-search-bar svg {
|
||||
color: #8283a0;
|
||||
}
|
||||
.detail-search-bar input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
color: #050841;
|
||||
width: 100%;
|
||||
}
|
||||
.detail-search-bar input::placeholder {
|
||||
color: #8283a0;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
min-height: 64px;
|
||||
padding-inline: max(16px, calc((100vw - 1180px) / 2));
|
||||
background: rgba(255, 255, 255, .96);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand img { width: 28px; height: 28px; }
|
||||
nav { display: flex; gap: 20px; }
|
||||
nav a { font-size: .875rem; text-decoration: none; color: var(--muted); }
|
||||
nav a:hover { color: var(--ink); }
|
||||
|
||||
|
||||
+342
-87
@@ -604,35 +604,185 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.recipe-cost-ledger > header { margin-bottom:26px; }
|
||||
.recipe-cost-ledger > header h2 { margin:0 0 6px !important; font-size:24px; }
|
||||
.recipe-cost-ledger > header p { margin:0; color:#8d92a2; font-size:14px; line-height:1.5; }
|
||||
.cost-ledger-heading { display:grid; grid-template-columns:minmax(0,1fr) 130px; gap:18px; padding:10px 18px; color:#a0a7bd; border-bottom:1px solid #eeeef3; font-size:12px; font-weight:600; }
|
||||
.cost-ledger-heading > span:last-child { text-align:right; }
|
||||
.cost-ledger-heading button { padding:0 3px; color:#4560ff; background:transparent; border:0; font:inherit; cursor:pointer; }
|
||||
.cost-ledger-heading i { color:#a0a7bd; font-style:normal; }
|
||||
.cost-ledger-line { border-bottom:1px solid #eeeef3; background:#fff; }
|
||||
.cost-ledger-line > summary,.cost-ledger-line.flat { display:grid; grid-template-columns:24px 30px minmax(0,1fr) 22px 130px; align-items:center; gap:10px; min-height:66px; padding:10px 18px; list-style:none; }
|
||||
.cost-ledger-line > summary::-webkit-details-marker { display:none; }
|
||||
.cost-ledger-line > summary { cursor:pointer; }
|
||||
.cost-ledger-line > summary::before { content:"⌄"; grid-column:1; color:#101a4a; font-size:20px; transform:rotate(-90deg); transition:.12s; }
|
||||
.cost-ledger-line[open] > summary::before { transform:none; }
|
||||
.cost-ledger-line.flat { grid-template-columns:24px 30px minmax(0,1fr) 22px 130px; }
|
||||
.cost-ledger-line.flat::before { content:""; }
|
||||
.cost-subject-icon { display:grid; grid-column:2; place-items:center; width:28px; height:28px; color:#fff; background:#54a19a; border-radius:50%; font-size:10px; }
|
||||
.cost-subject-icon.recipe { background:#6771a7; }
|
||||
.cost-attention,
|
||||
.ingredient-attention-icon,
|
||||
.application-body .cost-attention,
|
||||
.application-body .ingredient-attention-icon { display:inline-flex; align-items:center; justify-content:center; width:17px; height:17px; color:#f63d48; background:transparent; border:0; padding:0; margin-left:6px; vertical-align:middle; cursor:help; flex:none; }
|
||||
.cost-attention svg,
|
||||
.ingredient-attention-icon svg,
|
||||
.application-body .cost-attention svg,
|
||||
.application-body .ingredient-attention-icon svg { width:17px; height:17px; display:block; color:inherit; }
|
||||
.cost-line-value { grid-column:5; text-align:right; white-space:nowrap; }
|
||||
.cost-line-value a { color:#8d92a2; }
|
||||
.cost-line-detail { display:grid; grid-template-columns:minmax(0,1fr) minmax(120px,.4fr); gap:24px 40px; padding:18px 52px 28px; background:#fff; }
|
||||
.cost-line-detail > div:not(.cost-child-lines) { display:grid; gap:5px; }
|
||||
.cost-line-detail small { color:#a0a7bd; }
|
||||
.cost-line-detail strong { font-weight:500; }
|
||||
.cost-child-lines { grid-column:1/-1; border-top:1px solid #eeeef3; }
|
||||
.cost-ledger-heading {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 30px minmax(0, 1fr) 24px 140px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 18px;
|
||||
color: #8b93a7;
|
||||
border-bottom: 1px solid #eeeef3;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cost-ledger-heading .head-subject {
|
||||
grid-column: 3;
|
||||
}
|
||||
.cost-ledger-heading .head-cost {
|
||||
grid-column: 5;
|
||||
text-align: right;
|
||||
}
|
||||
.cost-ledger-heading button {
|
||||
padding: 0 4px;
|
||||
color: #3d5df6;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cost-ledger-heading button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cost-ledger-heading i {
|
||||
color: #cbd1dc;
|
||||
font-style: normal;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.cost-ledger-line {
|
||||
border-bottom: 1px solid #eeeef3;
|
||||
background: #fff;
|
||||
}
|
||||
.cost-ledger-line > summary {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 30px minmax(0, 1fr) 24px 140px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 56px;
|
||||
padding: 10px 18px;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.cost-ledger-line > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.cost-ledger-line.has-children > summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cost-ledger-line.has-children > summary:hover {
|
||||
background: #f8faff;
|
||||
}
|
||||
.cost-ledger-line.flat > summary {
|
||||
cursor: default;
|
||||
}
|
||||
.cost-toggle-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #8b93a7;
|
||||
}
|
||||
.cost-toggle-marker .chevron-icon {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.cost-ledger-line[open] > summary .cost-toggle-marker .chevron-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.cost-subject-icon {
|
||||
display: grid;
|
||||
grid-column: 2;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: #fff;
|
||||
background: #54a19a;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cost-subject-icon.recipe {
|
||||
background: #6771a7;
|
||||
}
|
||||
.cost-subject-name {
|
||||
grid-column: 3;
|
||||
color: #050841;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cost-subject-name:hover {
|
||||
color: #3d5df6;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cost-attention-slot {
|
||||
grid-column: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cost-attention-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #f3a642;
|
||||
background: #fff8eb;
|
||||
border-radius: 50%;
|
||||
cursor: help;
|
||||
position: relative;
|
||||
}
|
||||
.cost-attention-badge svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.cost-line-value {
|
||||
grid-column: 5;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #050841;
|
||||
}
|
||||
.cost-editable-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
color: #3d5df6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.cost-editable-link:hover {
|
||||
background: #f1f5fe;
|
||||
color: #202962;
|
||||
}
|
||||
.cost-edit-icon {
|
||||
font-size: 12px;
|
||||
color: #8b93a7;
|
||||
}
|
||||
.cost-editable-link:hover .cost-edit-icon {
|
||||
color: #3d5df6;
|
||||
}
|
||||
.cost-line-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, .4fr);
|
||||
gap: 20px 40px;
|
||||
padding: 16px 54px 24px;
|
||||
background: #fbfbfd;
|
||||
border-top: 1px solid #f0f2f6;
|
||||
}
|
||||
.cost-line-detail > div:not(.cost-child-lines) {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.cost-line-detail small {
|
||||
color: #8b93a7;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cost-line-detail strong {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cost-child-lines {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid #eeeef3;
|
||||
}
|
||||
.cost-summary { width:min(520px,100%); margin:52px 0 0 auto; }
|
||||
.cost-summary > div { display:grid; gap:8px; margin-bottom:18px; }
|
||||
.cost-summary > div > strong { font-size:14px; }
|
||||
@@ -753,8 +903,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.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 { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 14px; background: #fff; border: 1px solid #dfe3ec; border-radius: 999px; 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; }
|
||||
|
||||
@@ -1660,14 +1809,14 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .action-link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
color: #3d5df6;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
@@ -1675,6 +1824,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
display: block;
|
||||
}
|
||||
.application-body .action-link-btn:hover {
|
||||
color: #202962;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.application-body .bulk-add-trigger,
|
||||
@@ -1682,22 +1832,26 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
color: #050841;
|
||||
background: transparent;
|
||||
background: #ffffff;
|
||||
border: 1px solid #050841;
|
||||
border-radius: 100px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, border-color 0.12s ease;
|
||||
text-decoration: none;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.application-body .bulk-add-trigger:hover,
|
||||
.application-body .bulk-prep-btn:hover {
|
||||
background: #f1f4fb;
|
||||
color: #3d5df6;
|
||||
border-color: #3d5df6;
|
||||
background: #f1f5fe;
|
||||
text-decoration: none;
|
||||
}
|
||||
.application-body .unified-recipe-editor .add-line {
|
||||
@@ -2392,24 +2546,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
}
|
||||
|
||||
/* Cost ledger: meez accordion-list language. */
|
||||
.application-body .recipe-derived-panel .cost-ledger-heading { display:flex; align-items:center; gap:18px; padding:0 0 4px 14px; color:#a5a9c1; background:transparent; border-bottom:1px solid #f3f3f3; font-size:12px; font-weight:500; }
|
||||
.application-body .recipe-read-shell .recipe-derived-panel .cost-ledger-heading > span:last-child { margin:0 45px 0 30px; }
|
||||
.application-body .recipe-derived-panel .cost-ledger-heading a,.application-body .recipe-derived-panel .cost-ledger-heading button { padding:0 3px; color:#3d5df6; background:transparent; border:0; font-size:12px; font-weight:500; cursor:pointer; }
|
||||
.application-body .recipe-derived-panel .cost-ledger-lines { border-top:0; }
|
||||
.application-body .recipe-derived-panel .cost-ledger-line { min-height:48px; padding:12px 14px; background:transparent; border-bottom:1px solid #f3f3f3; }
|
||||
.application-body .recipe-derived-panel .cost-ledger-line:hover { background:#f1f5fe; }
|
||||
.application-body .recipe-derived-panel .recipe-cost-ledger header { margin-bottom:20px; }
|
||||
.application-body .recipe-derived-panel .cost-ledger-line a { color:#050841; font-size:15px; font-weight:400; text-decoration:none; }
|
||||
.application-body .recipe-read-shell .recipe-derived-panel .cost-ledger-line .cost-subject-icon {
|
||||
display:grid;
|
||||
width:24px;
|
||||
height:24px;
|
||||
margin-right:8px;
|
||||
background:#3f908a;
|
||||
color:#fff;
|
||||
font-size:10px;
|
||||
}
|
||||
.application-body .recipe-derived-panel .cost-line-value { color:#050841; font-size:15px; font-weight:400; }
|
||||
.application-body .recipe-derived-panel .cost-line-detail { color:#a5a9c1; font-size:14px; font-weight:400; }
|
||||
.application-body .recipe-read-shell .recipe-derived-panel .cost-line-detail small { margin-bottom:3px; color:#a5a9c1; font-size:14px; font-weight:400; }
|
||||
.application-body .recipe-read-shell .recipe-derived-panel .cost-line-detail strong { color:#050841; font-size:15px; font-weight:400; }
|
||||
@@ -2614,41 +2751,57 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
margin-left:auto;
|
||||
padding:0;
|
||||
}
|
||||
.application-body .immersive-main .recipe-read-shell .entity-header-actions .edit-command {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
padding:6px 8px;
|
||||
color:#3d5df6;
|
||||
background:transparent;
|
||||
border:0;
|
||||
border-radius:0;
|
||||
font-size:15px;
|
||||
font-weight:500;
|
||||
text-decoration:none;
|
||||
}
|
||||
.application-body .immersive-main .recipe-read-shell .entity-header-actions #recipe-done,
|
||||
.application-body .immersive-main .entity-header-actions .primary-command {
|
||||
.application-body .immersive-main .recipe-read-shell .entity-header-actions .edit-command,
|
||||
.application-body .entity-header-actions .edit-command {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
color: #fff;
|
||||
background: #40b49a;
|
||||
border: 1px solid #40b49a;
|
||||
border-radius: 100px;
|
||||
color: #3d5df6;
|
||||
background: #ffffff;
|
||||
border: 1px solid #3d5df6;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.application-body .immersive-main .recipe-read-shell .entity-header-actions .edit-command:hover,
|
||||
.application-body .entity-header-actions .edit-command:hover {
|
||||
color: #202962;
|
||||
border-color: #202962;
|
||||
background: #f1f5fe;
|
||||
text-decoration: none;
|
||||
}
|
||||
.application-body .immersive-main .recipe-read-shell .entity-header-actions #recipe-done,
|
||||
.application-body .immersive-main .entity-header-actions .primary-command,
|
||||
.application-body .entity-header-actions #ingredient-done {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
color: #ffffff;
|
||||
background: #3d5df6;
|
||||
border: 1px solid #3d5df6;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.12s ease;
|
||||
transition: background-color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.application-body .immersive-main .recipe-read-shell .entity-header-actions #recipe-done:hover,
|
||||
.application-body .immersive-main .entity-header-actions .primary-command:hover {
|
||||
background: #359882;
|
||||
.application-body .immersive-main .entity-header-actions .primary-command:hover,
|
||||
.application-body .entity-header-actions #ingredient-done:hover {
|
||||
background: #2b4be0;
|
||||
border-color: #2b4be0;
|
||||
}
|
||||
.application-body .immersive-main .recipe-read-shell .entity-header-actions .edit-command svg { width:18px; height:18px; }
|
||||
.application-body .immersive-main .recipe-read-shell .entity-header-actions .detail-actions-menu > summary { width:32px; height:32px; }
|
||||
@@ -2738,12 +2891,6 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
text-align:left;
|
||||
-moz-appearance:textfield;
|
||||
}
|
||||
.application-body .immersive-main .recipe-read-shell .recipe-view-formula .calculator-heading .scale-suffix {
|
||||
color:#050841;
|
||||
font-size:14px;
|
||||
font-weight:500;
|
||||
margin-left:2px;
|
||||
}
|
||||
.application-body .immersive-main .recipe-read-shell .recipe-view-formula .calculator-heading .quantity-control input {
|
||||
min-width:32px;
|
||||
max-width:10ch;
|
||||
@@ -4322,4 +4469,112 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
align-items: stretch !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
/* Cost Ledger Mobile */
|
||||
.application-body .cost-ledger-heading {
|
||||
grid-template-columns: 20px 26px minmax(0, 1fr) 20px 90px !important;
|
||||
gap: 8px !important;
|
||||
padding: 8px 12px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
.application-body .cost-ledger-line > summary {
|
||||
grid-template-columns: 20px 26px minmax(0, 1fr) 20px 90px !important;
|
||||
gap: 8px !important;
|
||||
padding: 8px 12px !important;
|
||||
min-height: 48px !important;
|
||||
}
|
||||
.application-body .cost-subject-name {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.application-body .cost-line-value {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
.application-body .cost-line-detail {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 12px !important;
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
.application-body .cost-summary {
|
||||
width: 100% !important;
|
||||
margin-top: 24px !important;
|
||||
}
|
||||
|
||||
/* Inventory Workspace Mobile */
|
||||
.application-body .inventory-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
padding: 16px 0 40px !important;
|
||||
}
|
||||
.application-body .inventory-header {
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 14px !important;
|
||||
}
|
||||
.application-body .inventory-header-actions {
|
||||
width: 100% !important;
|
||||
}
|
||||
.application-body .primary-count-btn {
|
||||
width: 100% !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.application-body .count-sheet-header {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
gap: 14px !important;
|
||||
}
|
||||
.application-body .count-sheet-header-right {
|
||||
flex-wrap: wrap !important;
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
.application-body .location-tabs {
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
scrollbar-width: none !important;
|
||||
padding-bottom: 6px !important;
|
||||
}
|
||||
.application-body .location-tabs::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
.application-body .count-table-container {
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
.application-body .add-item-bar {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
.application-body .quick-add-dropdown {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.application-body .quick-add-btn {
|
||||
width: 100% !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* Archive Floating Bar Mobile */
|
||||
.application-body .archive-floating-bar {
|
||||
width: calc(100% - 32px) !important;
|
||||
max-width: 400px !important;
|
||||
box-sizing: border-box !important;
|
||||
flex-direction: column !important;
|
||||
gap: 10px !important;
|
||||
padding: 12px 16px !important;
|
||||
bottom: 16px !important;
|
||||
}
|
||||
.application-body .floating-bar-actions {
|
||||
width: 100% !important;
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
/* Formula Calculator Heading Mobile */
|
||||
.application-body .recipe-view-formula .calculator-heading {
|
||||
gap: 14px 20px !important;
|
||||
margin-bottom: 18px !important;
|
||||
}
|
||||
.application-body .recipe-view-formula .calculator-percent-controls {
|
||||
width: 100% !important;
|
||||
justify-content: flex-start !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user