diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0025d09
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+.venv/
+__pycache__/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+dist/
+build/
+node_modules/
+.svelte-kit/
+backend/.venv/
+backend/.pytest_cache/
+frontend/node_modules/
+*.pyc
+*.pyo
+*.pyd
+*.sqlite3
+*.db
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6bc7989
--- /dev/null
+++ b/README.md
@@ -0,0 +1,53 @@
+# Data Entry App
+
+Initial MVP implementation of the costing platform described in `CLAUDE.MD`.
+
+## Structure
+
+```text
+backend/ FastAPI API, SQLAlchemy models, costing engine, seed data, tests
+frontend/ SvelteKit UI scaffold for dashboard and core modules
+```
+
+## Backend
+
+Create a virtual environment, install dependencies, then run:
+
+```bash
+cd backend
+pip install -e .
+uvicorn app.main:app --reload
+```
+
+API docs will be available at `http://localhost:8000/docs`.
+
+Useful commands:
+
+```bash
+python -m app.seed
+pytest
+```
+
+The backend defaults to SQLite for the prototype and can be switched with the
+`DATABASE_URL` environment variable.
+
+## Frontend
+
+Install dependencies and start the dev server:
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+Set `PUBLIC_API_BASE_URL` if the backend is not running on `http://localhost:8000`.
+
+## Delivered in this MVP
+
+- Raw materials with versioned prices
+- Mixes with ingredient rows and calculated cost per kg
+- Products with transparent cost breakdowns
+- Scenario runs with override support
+- Power BI-style reporting endpoints
+- SvelteKit dashboard and module pages aligned to the API contract
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/backend/app/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/backend/app/api/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
new file mode 100644
index 0000000..aca4201
--- /dev/null
+++ b/backend/app/api/auth.py
@@ -0,0 +1,28 @@
+from fastapi import APIRouter, HTTPException, status
+from pydantic import BaseModel
+
+from app.core.config import settings
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+
+
+class LoginRequest(BaseModel):
+ email: str
+ password: str
+
+
+class LoginResponse(BaseModel):
+ name: str
+ email: str
+ role: str
+
+
+@router.post("/login", response_model=LoginResponse)
+def login(payload: LoginRequest):
+ if payload.email.strip().lower() != settings.operator_email.lower() or payload.password != settings.operator_password:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
+ return {
+ "name": settings.operator_name,
+ "email": settings.operator_email,
+ "role": "operator",
+ }
diff --git a/backend/app/api/mixes.py b/backend/app/api/mixes.py
new file mode 100644
index 0000000..aa1f32a
--- /dev/null
+++ b/backend/app/api/mixes.py
@@ -0,0 +1,101 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.models.mix import Mix, MixIngredient
+from app.models.raw_material import RawMaterial
+from app.schemas.mix import MixCreate, MixIngredientCreate, MixIngredientUpdate, MixRead, MixUpdate
+from app.services.costing_engine import calculate_mix_cost
+
+router = APIRouter(prefix="/api/mixes", tags=["mixes"])
+
+
+@router.get("", response_model=list[MixRead])
+def list_mixes(db: Session = Depends(get_db)):
+ mixes = db.scalars(select(Mix).order_by(Mix.name)).all()
+ return [calculate_mix_cost(db, mix.id) for mix in mixes]
+
+
+@router.post("", response_model=MixRead, status_code=status.HTTP_201_CREATED)
+def create_mix(payload: MixCreate, db: Session = Depends(get_db)):
+ mix = Mix(
+ client_name=payload.client_name,
+ name=payload.name,
+ status=payload.status,
+ version=payload.version,
+ notes=payload.notes,
+ )
+ db.add(mix)
+ db.flush()
+ for ingredient in payload.ingredients:
+ if db.scalar(select(RawMaterial).where(RawMaterial.id == ingredient.raw_material_id)) is None:
+ raise HTTPException(status_code=404, detail=f"Raw material {ingredient.raw_material_id} not found")
+ db.add(
+ MixIngredient(
+ mix_id=mix.id,
+ raw_material_id=ingredient.raw_material_id,
+ quantity_kg=ingredient.quantity_kg,
+ notes=ingredient.notes,
+ )
+ )
+ db.commit()
+ return calculate_mix_cost(db, mix.id)
+
+
+@router.get("/{mix_id}", response_model=MixRead)
+def get_mix(mix_id: int, db: Session = Depends(get_db)):
+ if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None:
+ raise HTTPException(status_code=404, detail="Mix not found")
+ return calculate_mix_cost(db, mix_id)
+
+
+@router.patch("/{mix_id}", response_model=MixRead)
+def update_mix(mix_id: int, payload: MixUpdate, db: Session = Depends(get_db)):
+ mix = db.scalar(select(Mix).where(Mix.id == mix_id))
+ if mix is None:
+ raise HTTPException(status_code=404, detail="Mix not found")
+ for field, value in payload.model_dump(exclude_unset=True).items():
+ setattr(mix, field, value)
+ db.commit()
+ return calculate_mix_cost(db, mix_id)
+
+
+@router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED)
+def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, db: Session = Depends(get_db)):
+ if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None:
+ raise HTTPException(status_code=404, detail="Mix not found")
+ if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id)) is None:
+ raise HTTPException(status_code=404, detail="Raw material not found")
+ db.add(MixIngredient(mix_id=mix_id, raw_material_id=payload.raw_material_id, quantity_kg=payload.quantity_kg, notes=payload.notes))
+ db.commit()
+ return calculate_mix_cost(db, mix_id)
+
+
+@router.patch("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
+def update_mix_ingredient(mix_id: int, ingredient_id: int, payload: MixIngredientUpdate, db: Session = Depends(get_db)):
+ ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id))
+ if ingredient is None:
+ raise HTTPException(status_code=404, detail="Ingredient not found")
+ for field, value in payload.model_dump(exclude_unset=True).items():
+ setattr(ingredient, field, value)
+ db.commit()
+ return calculate_mix_cost(db, mix_id)
+
+
+@router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
+def delete_mix_ingredient(mix_id: int, ingredient_id: int, db: Session = Depends(get_db)):
+ ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id))
+ if ingredient is None:
+ raise HTTPException(status_code=404, detail="Ingredient not found")
+ db.delete(ingredient)
+ db.commit()
+ return calculate_mix_cost(db, mix_id)
+
+
+@router.get("/{mix_id}/cost-breakdown", response_model=MixRead)
+def get_mix_cost_breakdown(mix_id: int, db: Session = Depends(get_db)):
+ if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None:
+ raise HTTPException(status_code=404, detail="Mix not found")
+ return calculate_mix_cost(db, mix_id)
+
diff --git a/backend/app/api/powerbi.py b/backend/app/api/powerbi.py
new file mode 100644
index 0000000..07c30b7
--- /dev/null
+++ b/backend/app/api/powerbi.py
@@ -0,0 +1,64 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.models.mix import Mix
+from app.models.product import Product
+from app.models.raw_material import RawMaterial
+from app.models.scenario import Scenario
+from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, serialize_raw_material
+
+router = APIRouter(prefix="/api/powerbi", tags=["powerbi"])
+
+
+@router.get("/raw-material-costs")
+def raw_material_costs(db: Session = Depends(get_db)):
+ materials = db.scalars(select(RawMaterial).order_by(RawMaterial.name)).all()
+ return [serialize_raw_material(material) for material in materials]
+
+
+@router.get("/mix-costs")
+def mix_costs(db: Session = Depends(get_db)):
+ mixes = db.scalars(select(Mix).order_by(Mix.name)).all()
+ return [calculate_mix_cost(db, mix.id) for mix in mixes]
+
+
+@router.get("/product-costs")
+def product_costs(db: Session = Depends(get_db)):
+ products = db.scalars(select(Product).order_by(Product.name)).all()
+ return [calculate_product_cost(db, product.id) for product in products]
+
+
+@router.get("/scenario-results")
+def scenario_results(db: Session = Depends(get_db)):
+ scenarios = db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all()
+ return [
+ {
+ "scenario_id": scenario.id,
+ "scenario_name": scenario.name,
+ "status": scenario.status,
+ "description": scenario.description,
+ "overrides": scenario.overrides,
+ }
+ for scenario in scenarios
+ ]
+
+
+@router.get("/data-quality-issues")
+def data_quality_issues(db: Session = Depends(get_db)):
+ issues: list[dict] = []
+ for mix in db.scalars(select(Mix)).all():
+ result = calculate_mix_cost(db, mix.id)
+ for warning in result["warnings"]:
+ issues.append({"entity_type": "mix", "entity_id": mix.id, "entity_name": mix.name, "warning": warning})
+ for product in db.scalars(select(Product)).all():
+ result = calculate_product_cost(db, product.id)
+ for warning in result["warnings"]:
+ issues.append({"entity_type": "product", "entity_id": product.id, "entity_name": product.name, "warning": warning})
+ for material in db.scalars(select(RawMaterial)).all():
+ serialized = serialize_raw_material(material)
+ if serialized["current_price"] is None:
+ issues.append({"entity_type": "raw_material", "entity_id": material.id, "entity_name": material.name, "warning": "No active price"})
+ return issues
+
diff --git a/backend/app/api/products.py b/backend/app/api/products.py
new file mode 100644
index 0000000..e9e87bb
--- /dev/null
+++ b/backend/app/api/products.py
@@ -0,0 +1,88 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.models.mix import Mix
+from app.models.product import Product
+from app.schemas.product import ProductCostBreakdown, ProductCreate, ProductRead, ProductUpdate
+from app.services.costing_engine import calculate_product_cost
+
+router = APIRouter(prefix="/api/products", tags=["products"])
+
+
+def _serialize_product(product: Product) -> dict:
+ return {
+ "id": product.id,
+ "tenant_id": product.tenant_id,
+ "client_name": product.client_name,
+ "item_id": product.item_id,
+ "name": product.name,
+ "mix_id": product.mix_id,
+ "mix_name": product.mix.name if product.mix else "",
+ "sale_type": product.sale_type,
+ "own_bag": product.own_bag,
+ "unit_of_measure": product.unit_of_measure,
+ "items_per_pallet": product.items_per_pallet,
+ "bagging_process": product.bagging_process,
+ "distributor_margin": product.distributor_margin,
+ "wholesale_margin": product.wholesale_margin,
+ "notes": product.notes,
+ "created_at": product.created_at,
+ }
+
+
+@router.get("", response_model=list[ProductRead])
+def list_products(db: Session = Depends(get_db)):
+ products = db.scalars(select(Product).order_by(Product.name)).all()
+ return [_serialize_product(product) for product in products]
+
+
+@router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED)
+def create_product(payload: ProductCreate, db: Session = Depends(get_db)):
+ if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None:
+ raise HTTPException(status_code=404, detail="Mix not found")
+ product = Product(**payload.model_dump())
+ db.add(product)
+ db.commit()
+ db.refresh(product)
+ return _serialize_product(product)
+
+
+@router.get("/{product_id}", response_model=ProductRead)
+def get_product(product_id: int, db: Session = Depends(get_db)):
+ product = db.scalar(select(Product).where(Product.id == product_id))
+ if product is None:
+ raise HTTPException(status_code=404, detail="Product not found")
+ return _serialize_product(product)
+
+
+@router.patch("/{product_id}", response_model=ProductRead)
+def update_product(product_id: int, payload: ProductUpdate, db: Session = Depends(get_db)):
+ product = db.scalar(select(Product).where(Product.id == product_id))
+ if product is None:
+ raise HTTPException(status_code=404, detail="Product not found")
+ if payload.mix_id is not None and db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None:
+ raise HTTPException(status_code=404, detail="Mix not found")
+ for field, value in payload.model_dump(exclude_unset=True).items():
+ setattr(product, field, value)
+ db.commit()
+ db.refresh(product)
+ return _serialize_product(product)
+
+
+@router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown)
+def get_product_cost_breakdown(product_id: int, db: Session = Depends(get_db)):
+ try:
+ return calculate_product_cost(db, product_id)
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+
+
+@router.get("/{product_id}/price-output", response_model=ProductCostBreakdown)
+def get_product_price_output(product_id: int, db: Session = Depends(get_db)):
+ try:
+ return calculate_product_cost(db, product_id)
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+
diff --git a/backend/app/api/raw_materials.py b/backend/app/api/raw_materials.py
new file mode 100644
index 0000000..cf1514b
--- /dev/null
+++ b/backend/app/api/raw_materials.py
@@ -0,0 +1,122 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.orm import Session, selectinload
+
+from app.db.session import get_db
+from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
+from app.schemas.raw_material import (
+ RawMaterialCreate,
+ RawMaterialPriceVersionCreate,
+ RawMaterialPriceVersionRead,
+ RawMaterialRead,
+ RawMaterialUpdate,
+)
+from app.services.costing_engine import calculate_raw_material_cost, serialize_raw_material
+
+router = APIRouter(prefix="/api/raw-materials", tags=["raw-materials"])
+
+
+def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> dict:
+ price_comp = calculate_raw_material_cost(material, price)
+ return {
+ "id": price.id,
+ "market_value": price.market_value,
+ "waste_percentage": price.waste_percentage,
+ "effective_date": price.effective_date,
+ "status": price.status,
+ "notes": price.notes,
+ "created_at": price.created_at,
+ "loss_cost": price_comp.loss_cost,
+ "cost_per_unit": price_comp.cost_per_unit,
+ "cost_per_kg": price_comp.cost_per_kg,
+ }
+
+
+@router.get("", response_model=list[RawMaterialRead])
+def list_raw_materials(db: Session = Depends(get_db)):
+ materials = db.scalars(select(RawMaterial).options(selectinload(RawMaterial.price_versions)).order_by(RawMaterial.name)).all()
+ return [serialize_raw_material(material) for material in materials]
+
+
+@router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED)
+def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db)):
+ material = RawMaterial(
+ name=payload.name,
+ supplier=payload.supplier,
+ unit_of_measure=payload.unit_of_measure,
+ kg_per_unit=payload.kg_per_unit,
+ status=payload.status,
+ notes=payload.notes,
+ )
+ material.price_versions.append(
+ RawMaterialPriceVersion(
+ market_value=payload.initial_price.market_value,
+ waste_percentage=payload.initial_price.waste_percentage,
+ effective_date=payload.initial_price.effective_date,
+ status=payload.initial_price.status,
+ notes=payload.initial_price.notes,
+ )
+ )
+ db.add(material)
+ db.commit()
+ db.refresh(material)
+ return serialize_raw_material(material)
+
+
+@router.get("/{raw_material_id}", response_model=RawMaterialRead)
+def get_raw_material(raw_material_id: int, db: Session = Depends(get_db)):
+ material = db.scalar(
+ select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions))
+ )
+ if material is None:
+ raise HTTPException(status_code=404, detail="Raw material not found")
+ return serialize_raw_material(material)
+
+
+@router.patch("/{raw_material_id}", response_model=RawMaterialRead)
+def update_raw_material(raw_material_id: int, payload: RawMaterialUpdate, db: Session = Depends(get_db)):
+ material = db.scalar(
+ select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions))
+ )
+ if material is None:
+ raise HTTPException(status_code=404, detail="Raw material not found")
+ for field, value in payload.model_dump(exclude_unset=True).items():
+ setattr(material, field, value)
+ db.commit()
+ db.refresh(material)
+ return serialize_raw_material(material)
+
+
+@router.post("/{raw_material_id}/prices", response_model=RawMaterialPriceVersionRead, status_code=status.HTTP_201_CREATED)
+def add_price_version(raw_material_id: int, payload: RawMaterialPriceVersionCreate, db: Session = Depends(get_db)):
+ material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id))
+ if material is None:
+ raise HTTPException(status_code=404, detail="Raw material not found")
+ price = RawMaterialPriceVersion(
+ raw_material_id=raw_material_id,
+ market_value=payload.market_value,
+ waste_percentage=payload.waste_percentage,
+ effective_date=payload.effective_date,
+ status=payload.status,
+ notes=payload.notes,
+ )
+ db.add(price)
+ db.commit()
+ db.refresh(price)
+ return _serialize_price(material, price)
+
+
+@router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead])
+def get_price_history(raw_material_id: int, db: Session = Depends(get_db)):
+ material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id))
+ if material is None:
+ raise HTTPException(status_code=404, detail="Raw material not found")
+ prices = db.scalars(
+ select(RawMaterialPriceVersion)
+ .where(RawMaterialPriceVersion.raw_material_id == raw_material_id)
+ .order_by(RawMaterialPriceVersion.effective_date.desc())
+ ).all()
+ items = []
+ for price in prices:
+ items.append(_serialize_price(material, price))
+ return items
diff --git a/backend/app/api/scenarios.py b/backend/app/api/scenarios.py
new file mode 100644
index 0000000..4249eaa
--- /dev/null
+++ b/backend/app/api/scenarios.py
@@ -0,0 +1,84 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.models.scenario import CostingResult, Scenario
+from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse
+from app.services.scenario_engine import run_scenario
+
+router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
+
+
+@router.get("", response_model=list[ScenarioRead])
+def list_scenarios(db: Session = Depends(get_db)):
+ return db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all()
+
+
+@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED)
+def create_scenario(payload: ScenarioCreate, db: Session = Depends(get_db)):
+ scenario = Scenario(name=payload.name, description=payload.description, overrides=payload.overrides)
+ db.add(scenario)
+ db.commit()
+ db.refresh(scenario)
+ return scenario
+
+
+@router.get("/{scenario_id}", response_model=ScenarioRead)
+def get_scenario(scenario_id: int, db: Session = Depends(get_db)):
+ scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ return scenario
+
+
+@router.post("/{scenario_id}/run", response_model=ScenarioRunResponse)
+def run_scenario_endpoint(scenario_id: int, db: Session = Depends(get_db)):
+ scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ results = run_scenario(db, scenario)
+ db.refresh(scenario)
+ return {"scenario": scenario, "results": results}
+
+
+@router.get("/{scenario_id}/results")
+def get_scenario_results(scenario_id: int, db: Session = Depends(get_db)):
+ scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ results = db.scalars(select(CostingResult).where(CostingResult.scenario_id == scenario_id)).all()
+ return [
+ {
+ "product_id": result.product_id,
+ "finished_product_delivered": result.finished_product_delivered,
+ "distributor_price": result.distributor_price,
+ "wholesale_price": result.wholesale_price,
+ "warnings": result.warnings,
+ "details": result.details,
+ }
+ for result in results
+ ]
+
+
+@router.post("/{scenario_id}/approve", response_model=ScenarioRead)
+def approve_scenario(scenario_id: int, db: Session = Depends(get_db)):
+ scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ scenario.status = "approved"
+ db.commit()
+ db.refresh(scenario)
+ return scenario
+
+
+@router.post("/{scenario_id}/reject", response_model=ScenarioRead)
+def reject_scenario(scenario_id: int, db: Session = Depends(get_db)):
+ scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ scenario.status = "rejected"
+ db.commit()
+ db.refresh(scenario)
+ return scenario
+
diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/backend/app/core/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
new file mode 100644
index 0000000..e38d5c0
--- /dev/null
+++ b/backend/app/core/config.py
@@ -0,0 +1,24 @@
+import os
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Settings:
+ app_name: str
+ database_url: str
+ operator_name: str
+ operator_email: str
+ operator_password: str
+
+ @classmethod
+ def from_env(cls) -> "Settings":
+ return cls(
+ app_name=os.getenv("APP_NAME", "Data Entry App API"),
+ database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"),
+ operator_name=os.getenv("OPERATOR_NAME", "Operations Manager"),
+ operator_email=os.getenv("OPERATOR_EMAIL", "operator@example.com"),
+ operator_password=os.getenv("OPERATOR_PASSWORD", "changeme"),
+ )
+
+
+settings = Settings.from_env()
diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/backend/app/db/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/app/db/session.py b/backend/app/db/session.py
new file mode 100644
index 0000000..8c632de
--- /dev/null
+++ b/backend/app/db/session.py
@@ -0,0 +1,23 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import DeclarativeBase, sessionmaker
+
+from app.core.config import settings
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
+
+engine = create_engine(settings.database_url, connect_args=connect_args)
+SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..1ee1b01
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,81 @@
+import os
+import sys
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+if __package__ in {None, ""}:
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+import uvicorn
+
+from app.api.auth import router as auth_router
+from app.api.mixes import router as mixes_router
+from app.api.powerbi import router as powerbi_router
+from app.api.products import router as products_router
+from app.api.raw_materials import router as raw_materials_router
+from app.api.scenarios import router as scenarios_router
+from app.core.config import settings
+from app.db.session import Base, engine
+from app.seed import seed_if_empty
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI):
+ Base.metadata.create_all(bind=engine)
+ seed_if_empty()
+ yield
+
+
+app = FastAPI(title=settings.app_name, lifespan=lifespan)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+app.include_router(auth_router)
+app.include_router(raw_materials_router)
+app.include_router(mixes_router)
+app.include_router(products_router)
+app.include_router(scenarios_router)
+app.include_router(powerbi_router)
+
+
+@app.get("/")
+def root():
+ return {
+ "app": settings.app_name,
+ "message": "Use the operator frontend to sign in, manage raw materials, and review downstream mix and product costs.",
+ "workflow": [
+ "Sign in as an operator",
+ "Update raw material prices or add new materials",
+ "Review mix master recalculations",
+ "Confirm finished product pricing outputs",
+ ],
+ "endpoints": {
+ "login": "/api/auth/login",
+ "raw_materials": "/api/raw-materials",
+ "mixes": "/api/mixes",
+ "products": "/api/products",
+ "scenarios": "/api/scenarios",
+ "docs": "/docs",
+ },
+ }
+
+
+@app.get("/health")
+def healthcheck():
+ return {"status": "ok"}
+
+
+if __name__ == "__main__":
+ uvicorn.run(
+ app,
+ host=os.getenv("HOST", "0.0.0.0"),
+ port=int(os.getenv("PORT", "8000")),
+ )
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..03ac0a3
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -0,0 +1,19 @@
+from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
+from app.models.mix import Mix, MixIngredient
+from app.models.product import Product
+from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
+from app.models.scenario import CostingResult, Scenario
+
+__all__ = [
+ "CostingResult",
+ "FreightCostRule",
+ "Mix",
+ "MixIngredient",
+ "PackagingCostRule",
+ "ProcessCostRule",
+ "Product",
+ "RawMaterial",
+ "RawMaterialPriceVersion",
+ "Scenario",
+]
+
diff --git a/backend/app/models/assumption.py b/backend/app/models/assumption.py
new file mode 100644
index 0000000..6ed54de
--- /dev/null
+++ b/backend/app/models/assumption.py
@@ -0,0 +1,38 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Float, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.db.session import Base
+
+
+class ProcessCostRule(Base):
+ __tablename__ = "process_cost_rules"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ process_name: Mapped[str] = mapped_column(String(64), unique=True)
+ grading_cost: Mapped[float] = mapped_column(Float, default=0.0)
+ bagging_cost: Mapped[float] = mapped_column(Float, default=0.0)
+ cracking_cost: Mapped[float] = mapped_column(Float, default=0.0)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+
+class PackagingCostRule(Base):
+ __tablename__ = "packaging_cost_rules"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ sale_type: Mapped[str] = mapped_column(String(64))
+ unit_of_measure: Mapped[str] = mapped_column(String(64))
+ own_bag: Mapped[bool] = mapped_column(Boolean, default=False)
+ bag_cost: Mapped[float] = mapped_column(Float, default=0.0)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+
+class FreightCostRule(Base):
+ __tablename__ = "freight_cost_rules"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ sale_type: Mapped[str] = mapped_column(String(64))
+ unit_of_measure: Mapped[str] = mapped_column(String(64))
+ cost_per_unit: Mapped[float] = mapped_column(Float, default=0.0)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
diff --git a/backend/app/models/mix.py b/backend/app/models/mix.py
new file mode 100644
index 0000000..accb338
--- /dev/null
+++ b/backend/app/models/mix.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db.session import Base
+
+
+class Mix(Base):
+ __tablename__ = "mixes"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default")
+ client_name: Mapped[str] = mapped_column(String(255))
+ name: Mapped[str] = mapped_column(String(255), index=True)
+ status: Mapped[str] = mapped_column(String(32), default="draft")
+ version: Mapped[int] = mapped_column(default=1)
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ ingredients: Mapped[list["MixIngredient"]] = relationship(
+ back_populates="mix",
+ cascade="all, delete-orphan",
+ )
+ products: Mapped[list["Product"]] = relationship(back_populates="mix")
+
+
+class MixIngredient(Base):
+ __tablename__ = "mix_ingredients"
+ __table_args__ = (UniqueConstraint("mix_id", "raw_material_id", name="uq_mix_ingredient"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"), index=True)
+ raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True)
+ quantity_kg: Mapped[float] = mapped_column(Float)
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ mix: Mapped[Mix] = relationship(back_populates="ingredients")
+ raw_material: Mapped["RawMaterial"] = relationship()
+
+
+from app.models.product import Product # noqa: E402
+from app.models.raw_material import RawMaterial # noqa: E402
+
diff --git a/backend/app/models/product.py b/backend/app/models/product.py
new file mode 100644
index 0000000..2796a2d
--- /dev/null
+++ b/backend/app/models/product.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db.session import Base
+
+
+class Product(Base):
+ __tablename__ = "products"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default")
+ client_name: Mapped[str] = mapped_column(String(255))
+ item_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ name: Mapped[str] = mapped_column(String(255), index=True)
+ mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"))
+ sale_type: Mapped[str] = mapped_column(String(64), default="standard")
+ own_bag: Mapped[bool] = mapped_column(Boolean, default=False)
+ unit_of_measure: Mapped[str] = mapped_column(String(64), default="20kg bag")
+ items_per_pallet: Mapped[int] = mapped_column(Integer, default=50)
+ bagging_process: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
+ wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ mix: Mapped["Mix"] = relationship(back_populates="products")
+
+
+from app.models.mix import Mix # noqa: E402
+
diff --git a/backend/app/models/raw_material.py b/backend/app/models/raw_material.py
new file mode 100644
index 0000000..6ea44b8
--- /dev/null
+++ b/backend/app/models/raw_material.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db.session import Base
+
+
+class RawMaterial(Base):
+ __tablename__ = "raw_materials"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default")
+ name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
+ supplier: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ unit_of_measure: Mapped[str] = mapped_column(String(64))
+ kg_per_unit: Mapped[float] = mapped_column(Float)
+ status: Mapped[str] = mapped_column(String(32), default="active")
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ price_versions: Mapped[list["RawMaterialPriceVersion"]] = relationship(
+ back_populates="raw_material",
+ cascade="all, delete-orphan",
+ order_by="desc(RawMaterialPriceVersion.effective_date)",
+ )
+
+
+class RawMaterialPriceVersion(Base):
+ __tablename__ = "raw_material_price_versions"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True)
+ market_value: Mapped[float] = mapped_column(Float)
+ waste_percentage: Mapped[float] = mapped_column(Float, default=0.0)
+ effective_date: Mapped[date] = mapped_column(Date, default=date.today)
+ status: Mapped[str] = mapped_column(String(32), default="active")
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ raw_material: Mapped[RawMaterial] = relationship(back_populates="price_versions")
+
diff --git a/backend/app/models/scenario.py b/backend/app/models/scenario.py
new file mode 100644
index 0000000..4b7c0cd
--- /dev/null
+++ b/backend/app/models/scenario.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, JSON, String
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db.session import Base
+
+
+class Scenario(Base):
+ __tablename__ = "scenarios"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(255), unique=True)
+ status: Mapped[str] = mapped_column(String(32), default="draft")
+ description: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ overrides: Mapped[dict] = mapped_column(JSON, default=dict)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ results: Mapped[list["CostingResult"]] = relationship(
+ back_populates="scenario",
+ cascade="all, delete-orphan",
+ )
+
+
+class CostingResult(Base):
+ __tablename__ = "costing_results"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
+ product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
+ finished_product_delivered: Mapped[float] = mapped_column(Float)
+ distributor_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+ wholesale_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+ warnings: Mapped[list[str]] = mapped_column(JSON, default=list)
+ details: Mapped[dict] = mapped_column(JSON, default=dict)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ scenario: Mapped[Scenario] = relationship(back_populates="results")
+
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/backend/app/schemas/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/app/schemas/mix.py b/backend/app/schemas/mix.py
new file mode 100644
index 0000000..3476064
--- /dev/null
+++ b/backend/app/schemas/mix.py
@@ -0,0 +1,60 @@
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class MixIngredientCreate(BaseModel):
+ raw_material_id: int
+ quantity_kg: float = Field(gt=0)
+ notes: str | None = None
+
+
+class MixIngredientUpdate(BaseModel):
+ quantity_kg: float | None = Field(default=None, gt=0)
+ notes: str | None = None
+
+
+class MixIngredientRead(BaseModel):
+ id: int
+ raw_material_id: int
+ raw_material_name: str
+ quantity_kg: float
+ cost_per_kg: float | None
+ line_cost: float | None
+ notes: str | None
+ model_config = ConfigDict(from_attributes=True)
+
+
+class MixCreate(BaseModel):
+ client_name: str
+ name: str
+ status: str = "draft"
+ version: int = 1
+ notes: str | None = None
+ ingredients: list[MixIngredientCreate]
+
+
+class MixUpdate(BaseModel):
+ client_name: str | None = None
+ name: str | None = None
+ status: str | None = None
+ version: int | None = None
+ notes: str | None = None
+
+
+class MixRead(BaseModel):
+ id: int
+ tenant_id: str
+ client_name: str
+ name: str
+ status: str
+ version: int
+ notes: str | None
+ created_at: datetime
+ ingredients: list[MixIngredientRead]
+ total_mix_kg: float
+ total_mix_cost: float
+ mix_cost_per_kg: float | None
+ warnings: list[str]
+ model_config = ConfigDict(from_attributes=True)
+
diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py
new file mode 100644
index 0000000..645a461
--- /dev/null
+++ b/backend/app/schemas/product.py
@@ -0,0 +1,70 @@
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class ProductCreate(BaseModel):
+ client_name: str
+ item_id: str | None = None
+ name: str
+ mix_id: int
+ sale_type: str = "standard"
+ own_bag: bool = False
+ unit_of_measure: str = "20kg bag"
+ items_per_pallet: int = Field(default=50, gt=0)
+ bagging_process: str | None = None
+ distributor_margin: float | None = Field(default=None, gt=0, lt=1)
+ wholesale_margin: float | None = Field(default=None, gt=0, lt=1)
+ notes: str | None = None
+
+
+class ProductUpdate(BaseModel):
+ client_name: str | None = None
+ item_id: str | None = None
+ name: str | None = None
+ mix_id: int | None = None
+ sale_type: str | None = None
+ own_bag: bool | None = None
+ unit_of_measure: str | None = None
+ items_per_pallet: int | None = Field(default=None, gt=0)
+ bagging_process: str | None = None
+ distributor_margin: float | None = Field(default=None, gt=0, lt=1)
+ wholesale_margin: float | None = Field(default=None, gt=0, lt=1)
+ notes: str | None = None
+
+
+class ProductRead(BaseModel):
+ id: int
+ tenant_id: str
+ client_name: str
+ item_id: str | None
+ name: str
+ mix_id: int
+ mix_name: str
+ sale_type: str
+ own_bag: bool
+ unit_of_measure: str
+ items_per_pallet: int
+ bagging_process: str | None
+ distributor_margin: float | None
+ wholesale_margin: float | None
+ notes: str | None
+ created_at: datetime
+ model_config = ConfigDict(from_attributes=True)
+
+
+class ProductCostBreakdown(BaseModel):
+ product_id: int
+ product_name: str
+ cleaned_product_cost: float
+ grading_cost: float
+ bagging_cost: float
+ cracking_cost: float
+ bag_cost: float
+ freight_cost: float
+ finished_product_delivered: float
+ distributor_price: float | None
+ wholesale_price: float | None
+ warnings: list[str]
+ inputs: dict[str, object]
+
diff --git a/backend/app/schemas/raw_material.py b/backend/app/schemas/raw_material.py
new file mode 100644
index 0000000..d05d7a0
--- /dev/null
+++ b/backend/app/schemas/raw_material.py
@@ -0,0 +1,53 @@
+from datetime import date, datetime
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class RawMaterialPriceVersionCreate(BaseModel):
+ market_value: float = Field(gt=0)
+ waste_percentage: float = Field(ge=0, default=0.0)
+ effective_date: date
+ status: str = "active"
+ notes: str | None = None
+
+
+class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate):
+ id: int
+ created_at: datetime
+ loss_cost: float
+ cost_per_unit: float
+ cost_per_kg: float
+ model_config = ConfigDict(from_attributes=True)
+
+
+class RawMaterialCreate(BaseModel):
+ name: str
+ supplier: str | None = None
+ unit_of_measure: str
+ kg_per_unit: float = Field(gt=0)
+ status: str = "active"
+ notes: str | None = None
+ initial_price: RawMaterialPriceVersionCreate
+
+
+class RawMaterialUpdate(BaseModel):
+ supplier: str | None = None
+ unit_of_measure: str | None = None
+ kg_per_unit: float | None = Field(default=None, gt=0)
+ status: str | None = None
+ notes: str | None = None
+
+
+class RawMaterialRead(BaseModel):
+ id: int
+ tenant_id: str
+ name: str
+ supplier: str | None
+ unit_of_measure: str
+ kg_per_unit: float
+ status: str
+ notes: str | None
+ created_at: datetime
+ current_price: RawMaterialPriceVersionRead | None
+ model_config = ConfigDict(from_attributes=True)
+
diff --git a/backend/app/schemas/scenario.py b/backend/app/schemas/scenario.py
new file mode 100644
index 0000000..bf758fa
--- /dev/null
+++ b/backend/app/schemas/scenario.py
@@ -0,0 +1,26 @@
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from app.schemas.product import ProductCostBreakdown
+
+
+class ScenarioCreate(BaseModel):
+ name: str
+ description: str | None = None
+ overrides: dict = Field(default_factory=dict)
+
+
+class ScenarioRead(BaseModel):
+ id: int
+ name: str
+ status: str
+ description: str | None
+ overrides: dict
+ created_at: datetime
+ model_config = ConfigDict(from_attributes=True)
+
+
+class ScenarioRunResponse(BaseModel):
+ scenario: ScenarioRead
+ results: list[ProductCostBreakdown]
diff --git a/backend/app/seed.py b/backend/app/seed.py
new file mode 100644
index 0000000..40fbd3e
--- /dev/null
+++ b/backend/app/seed.py
@@ -0,0 +1,76 @@
+from datetime import date
+
+from sqlalchemy import select
+
+from app.db.session import Base, SessionLocal, engine
+from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
+from app.models.mix import Mix, MixIngredient
+from app.models.product import Product
+from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
+
+
+def seed_if_empty():
+ Base.metadata.create_all(bind=engine)
+ with SessionLocal() as db:
+ existing = db.scalar(select(RawMaterial.id))
+ if existing is not None:
+ return
+
+ maize = RawMaterial(name="Maize", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
+ barley = RawMaterial(name="Barley", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
+ acid_buf = RawMaterial(name="Acid Buf", supplier="Example Supplier", unit_of_measure="bag", kg_per_unit=25, status="active")
+
+ maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
+ barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
+ acid_buf.price_versions.append(RawMaterialPriceVersion(market_value=39, waste_percentage=0.0, effective_date=date(2026, 4, 1)))
+
+ db.add_all([maize, barley, acid_buf])
+ db.flush()
+
+ db.add_all(
+ [
+ ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0),
+ ProcessCostRule(process_name="bulk_loadout", grading_cost=0.03, bagging_cost=0.0, cracking_cost=0.0),
+ PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63),
+ PackagingCostRule(sale_type="bulka", unit_of_measure="550kg bulka", own_bag=False, bag_cost=7.5),
+ FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45),
+ FreightCostRule(sale_type="bulka", unit_of_measure="550kg bulka", cost_per_unit=18.0),
+ ]
+ )
+ db.flush()
+
+ mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1, notes="Seed recipe for MVP")
+ db.add(mix)
+ db.flush()
+
+ db.add_all(
+ [
+ MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
+ MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=95),
+ MixIngredient(mix_id=mix.id, raw_material_id=acid_buf.id, quantity_kg=5),
+ ]
+ )
+ db.flush()
+
+ db.add(
+ Product(
+ client_name="Specialty Feeds",
+ item_id="SKU-001",
+ name="Specialty Pigeon Breeder 20kg",
+ mix_id=mix.id,
+ sale_type="standard",
+ own_bag=False,
+ unit_of_measure="20kg bag",
+ items_per_pallet=50,
+ bagging_process="standard_bagging",
+ distributor_margin=0.225,
+ wholesale_margin=0.18,
+ notes="Reference product for formula parity work",
+ )
+ )
+ db.commit()
+
+
+if __name__ == "__main__":
+ seed_if_empty()
+
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/backend/app/services/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/app/services/costing_engine.py b/backend/app/services/costing_engine.py
new file mode 100644
index 0000000..25778bd
--- /dev/null
+++ b/backend/app/services/costing_engine.py
@@ -0,0 +1,277 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass
+import re
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session, selectinload
+
+from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
+from app.models.mix import Mix, MixIngredient
+from app.models.product import Product
+from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
+
+
+@dataclass
+class PriceComputation:
+ loss_cost: float
+ cost_per_unit: float
+ cost_per_kg: float
+
+
+def calculate_raw_material_cost(raw_material: RawMaterial, price: RawMaterialPriceVersion) -> PriceComputation:
+ loss_cost = price.market_value * price.waste_percentage
+ cost_per_unit = price.market_value + loss_cost
+ cost_per_kg = cost_per_unit / raw_material.kg_per_unit
+ return PriceComputation(loss_cost=round(loss_cost, 4), cost_per_unit=round(cost_per_unit, 4), cost_per_kg=round(cost_per_kg, 4))
+
+
+def get_active_price(raw_material: RawMaterial) -> RawMaterialPriceVersion | None:
+ active_prices = [price for price in raw_material.price_versions if price.status == "active"]
+ if not active_prices:
+ return None
+ active_prices.sort(key=lambda item: item.effective_date, reverse=True)
+ return active_prices[0]
+
+
+def calculate_mix_cost(db: Session, mix_id: int, overrides: dict | None = None) -> dict:
+ overrides = overrides or {}
+ mix = db.scalar(
+ select(Mix)
+ .where(Mix.id == mix_id)
+ .options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material).selectinload(RawMaterial.price_versions))
+ )
+ if mix is None:
+ raise ValueError(f"Mix {mix_id} not found")
+
+ total_mix_kg = 0.0
+ total_mix_cost = 0.0
+ warnings: list[str] = []
+ lines: list[dict] = []
+
+ for ingredient in mix.ingredients:
+ raw_material = ingredient.raw_material
+ active_price = get_active_price(raw_material)
+ if active_price is None:
+ warnings.append(f"{raw_material.name} has no active price")
+ lines.append(
+ {
+ "id": ingredient.id,
+ "raw_material_id": raw_material.id,
+ "raw_material_name": raw_material.name,
+ "quantity_kg": ingredient.quantity_kg,
+ "cost_per_kg": None,
+ "line_cost": None,
+ "notes": ingredient.notes,
+ }
+ )
+ total_mix_kg += ingredient.quantity_kg
+ continue
+
+ market_value = overrides.get("raw_material_market_values", {}).get(str(raw_material.id), active_price.market_value)
+ waste_percentage = overrides.get("raw_material_waste_percentages", {}).get(str(raw_material.id), active_price.waste_percentage)
+ price_stub = RawMaterialPriceVersion(
+ raw_material_id=raw_material.id,
+ market_value=market_value,
+ waste_percentage=waste_percentage,
+ effective_date=active_price.effective_date,
+ status=active_price.status,
+ )
+ price_comp = calculate_raw_material_cost(raw_material, price_stub)
+ line_cost = round(ingredient.quantity_kg * price_comp.cost_per_kg, 4)
+ total_mix_kg += ingredient.quantity_kg
+ total_mix_cost += line_cost
+ lines.append(
+ {
+ "id": ingredient.id,
+ "raw_material_id": raw_material.id,
+ "raw_material_name": raw_material.name,
+ "quantity_kg": ingredient.quantity_kg,
+ "cost_per_kg": price_comp.cost_per_kg,
+ "line_cost": line_cost,
+ "notes": ingredient.notes,
+ }
+ )
+
+ if total_mix_kg == 0:
+ warnings.append("Mix total kg is zero")
+ mix_cost_per_kg = None
+ else:
+ mix_cost_per_kg = round(total_mix_cost / total_mix_kg, 4)
+
+ if not mix.ingredients:
+ warnings.append("Mix has no ingredients")
+
+ return {
+ "id": mix.id,
+ "tenant_id": mix.tenant_id,
+ "client_name": mix.client_name,
+ "name": mix.name,
+ "status": mix.status,
+ "version": mix.version,
+ "notes": mix.notes,
+ "created_at": mix.created_at,
+ "ingredients": lines,
+ "total_mix_kg": round(total_mix_kg, 4),
+ "total_mix_cost": round(total_mix_cost, 4),
+ "mix_cost_per_kg": mix_cost_per_kg,
+ "warnings": warnings,
+ }
+
+
+def _get_process_costs(db: Session, process_name: str | None, overrides: dict) -> tuple[float, float, float, list[str]]:
+ if not process_name:
+ return 0.0, 0.0, 0.0, ["Missing bagging process"]
+
+ rule = db.scalar(select(ProcessCostRule).where(ProcessCostRule.process_name == process_name))
+ if rule is None:
+ return 0.0, 0.0, 0.0, [f"Process rule not found for {process_name}"]
+
+ override_costs = overrides.get("process_costs", {}).get(process_name, {})
+ grading = override_costs.get("grading_cost", rule.grading_cost)
+ bagging = override_costs.get("bagging_cost", rule.bagging_cost)
+ cracking = override_costs.get("cracking_cost", rule.cracking_cost)
+ return grading, bagging, cracking, []
+
+
+def _get_packaging_cost(db: Session, product: Product, overrides: dict) -> tuple[float, list[str]]:
+ if product.own_bag:
+ return 0.0, []
+
+ rule = db.scalar(
+ select(PackagingCostRule).where(
+ PackagingCostRule.sale_type == product.sale_type,
+ PackagingCostRule.unit_of_measure == product.unit_of_measure,
+ PackagingCostRule.own_bag == product.own_bag,
+ )
+ )
+ if rule is None:
+ return 0.0, ["Packaging rule not found"]
+
+ return overrides.get("packaging_costs", {}).get(str(rule.id), rule.bag_cost), []
+
+
+def _get_freight_cost(db: Session, product: Product, overrides: dict) -> tuple[float, list[str]]:
+ rule = db.scalar(
+ select(FreightCostRule).where(
+ FreightCostRule.sale_type == product.sale_type,
+ FreightCostRule.unit_of_measure == product.unit_of_measure,
+ )
+ )
+ if rule is None:
+ return 0.0, ["Freight rule not found"]
+ return overrides.get("freight_costs", {}).get(str(rule.id), rule.cost_per_unit), []
+
+
+def _apply_margin(cost: float, margin: float | None) -> float | None:
+ if margin is None:
+ return None
+ if margin >= 1:
+ raise ValueError("Margin must be lower than 1")
+ return round(cost / (1 - margin), 4)
+
+
+def _extract_unit_quantity_kg(unit_of_measure: str) -> float:
+ normalized = unit_of_measure.strip().lower()
+ if normalized == "tonne":
+ return 1000.0
+ if normalized == "kg":
+ return 1.0
+ match = re.search(r"(\d+(?:\.\d+)?)\s*kg", normalized)
+ if match:
+ return float(match.group(1))
+ return 1.0
+
+
+def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict:
+ overrides = overrides or {}
+ product = db.scalar(select(Product).where(Product.id == product_id).options(selectinload(Product.mix)))
+ if product is None:
+ raise ValueError(f"Product {product_id} not found")
+
+ mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
+ warnings = list(mix_result["warnings"])
+ sale_unit_kg = _extract_unit_quantity_kg(product.unit_of_measure)
+
+ mix_cost_per_kg = mix_result["mix_cost_per_kg"] or 0.0
+ cleaned_product_cost = round(mix_cost_per_kg * sale_unit_kg, 4)
+ grading_cost, bagging_cost, cracking_cost, process_warnings = _get_process_costs(db, product.bagging_process, overrides)
+ warnings.extend(process_warnings)
+ grading_cost = round(grading_cost * sale_unit_kg, 4)
+ bagging_cost = round(bagging_cost * sale_unit_kg, 4)
+ cracking_cost = round(cracking_cost * sale_unit_kg, 4)
+ bag_cost, packaging_warnings = _get_packaging_cost(db, product, overrides)
+ warnings.extend(packaging_warnings)
+ freight_cost, freight_warnings = _get_freight_cost(db, product, overrides)
+ warnings.extend(freight_warnings)
+
+ finished_product_delivered = round(
+ cleaned_product_cost + grading_cost + bagging_cost + cracking_cost + bag_cost + freight_cost,
+ 4,
+ )
+
+ distributor_margin = overrides.get("product_margins", {}).get(str(product.id), {}).get("distributor_margin", product.distributor_margin)
+ wholesale_margin = overrides.get("product_margins", {}).get(str(product.id), {}).get("wholesale_margin", product.wholesale_margin)
+
+ return {
+ "product_id": product.id,
+ "product_name": product.name,
+ "cleaned_product_cost": round(cleaned_product_cost, 4),
+ "grading_cost": round(grading_cost, 4),
+ "bagging_cost": round(bagging_cost, 4),
+ "cracking_cost": round(cracking_cost, 4),
+ "bag_cost": round(bag_cost, 4),
+ "freight_cost": round(freight_cost, 4),
+ "finished_product_delivered": finished_product_delivered,
+ "distributor_price": _apply_margin(finished_product_delivered, distributor_margin),
+ "wholesale_price": _apply_margin(finished_product_delivered, wholesale_margin),
+ "warnings": warnings,
+ "inputs": {
+ "mix": {
+ "mix_id": product.mix_id,
+ "mix_name": product.mix.name,
+ "total_mix_kg": mix_result["total_mix_kg"],
+ "total_mix_cost": mix_result["total_mix_cost"],
+ "mix_cost_per_kg": mix_result["mix_cost_per_kg"],
+ "sale_unit_kg": sale_unit_kg,
+ },
+ "product": {
+ "sale_type": product.sale_type,
+ "own_bag": product.own_bag,
+ "unit_of_measure": product.unit_of_measure,
+ "items_per_pallet": product.items_per_pallet,
+ "bagging_process": product.bagging_process,
+ "distributor_margin": distributor_margin,
+ "wholesale_margin": wholesale_margin,
+ },
+ },
+ }
+
+
+def serialize_raw_material(raw_material: RawMaterial) -> dict:
+ active_price = get_active_price(raw_material)
+ current_price = None
+ if active_price is not None:
+ price_comp = calculate_raw_material_cost(raw_material, active_price)
+ current_price = {
+ "id": active_price.id,
+ "market_value": active_price.market_value,
+ "waste_percentage": active_price.waste_percentage,
+ "effective_date": active_price.effective_date,
+ "status": active_price.status,
+ "notes": active_price.notes,
+ "created_at": active_price.created_at,
+ **asdict(price_comp),
+ }
+ return {
+ "id": raw_material.id,
+ "tenant_id": raw_material.tenant_id,
+ "name": raw_material.name,
+ "supplier": raw_material.supplier,
+ "unit_of_measure": raw_material.unit_of_measure,
+ "kg_per_unit": raw_material.kg_per_unit,
+ "status": raw_material.status,
+ "notes": raw_material.notes,
+ "created_at": raw_material.created_at,
+ "current_price": current_price,
+ }
diff --git a/backend/app/services/scenario_engine.py b/backend/app/services/scenario_engine.py
new file mode 100644
index 0000000..eff4476
--- /dev/null
+++ b/backend/app/services/scenario_engine.py
@@ -0,0 +1,32 @@
+from sqlalchemy import delete, select
+from sqlalchemy.orm import Session
+
+from app.models.product import Product
+from app.models.scenario import CostingResult, Scenario
+from app.services.costing_engine import calculate_product_cost
+
+
+def run_scenario(db: Session, scenario: Scenario) -> list[dict]:
+ db.execute(delete(CostingResult).where(CostingResult.scenario_id == scenario.id))
+ products = db.scalars(select(Product).order_by(Product.name)).all()
+ results: list[dict] = []
+
+ for product in products:
+ breakdown = calculate_product_cost(db, product.id, overrides=scenario.overrides or {})
+ db.add(
+ CostingResult(
+ scenario_id=scenario.id,
+ product_id=product.id,
+ finished_product_delivered=breakdown["finished_product_delivered"],
+ distributor_price=breakdown["distributor_price"],
+ wholesale_price=breakdown["wholesale_price"],
+ warnings=breakdown["warnings"],
+ details=breakdown,
+ )
+ )
+ results.append(breakdown)
+
+ scenario.status = "reviewed"
+ db.commit()
+ return results
+
diff --git a/backend/data_entry_app_backend.egg-info/PKG-INFO b/backend/data_entry_app_backend.egg-info/PKG-INFO
new file mode 100644
index 0000000..d795e5f
--- /dev/null
+++ b/backend/data_entry_app_backend.egg-info/PKG-INFO
@@ -0,0 +1,10 @@
+Metadata-Version: 2.4
+Name: data-entry-app-backend
+Version: 0.1.0
+Summary: Costing platform MVP backend
+Requires-Python: >=3.11
+Requires-Dist: fastapi<1.0,>=0.115
+Requires-Dist: uvicorn[standard]<1.0,>=0.30
+Requires-Dist: sqlalchemy<3.0,>=2.0
+Requires-Dist: pydantic<3.0,>=2.8
+Requires-Dist: pytest<9.0,>=8.0
diff --git a/backend/data_entry_app_backend.egg-info/SOURCES.txt b/backend/data_entry_app_backend.egg-info/SOURCES.txt
new file mode 100644
index 0000000..2082ccb
--- /dev/null
+++ b/backend/data_entry_app_backend.egg-info/SOURCES.txt
@@ -0,0 +1,34 @@
+pyproject.toml
+./app/__init__.py
+./app/main.py
+./app/seed.py
+./app/api/__init__.py
+./app/api/mixes.py
+./app/api/powerbi.py
+./app/api/products.py
+./app/api/raw_materials.py
+./app/api/scenarios.py
+./app/core/__init__.py
+./app/core/config.py
+./app/db/__init__.py
+./app/db/session.py
+./app/models/__init__.py
+./app/models/assumption.py
+./app/models/mix.py
+./app/models/product.py
+./app/models/raw_material.py
+./app/models/scenario.py
+./app/schemas/__init__.py
+./app/schemas/mix.py
+./app/schemas/product.py
+./app/schemas/raw_material.py
+./app/schemas/scenario.py
+./app/services/__init__.py
+./app/services/costing_engine.py
+./app/services/scenario_engine.py
+data_entry_app_backend.egg-info/PKG-INFO
+data_entry_app_backend.egg-info/SOURCES.txt
+data_entry_app_backend.egg-info/dependency_links.txt
+data_entry_app_backend.egg-info/requires.txt
+data_entry_app_backend.egg-info/top_level.txt
+tests/test_costing_engine.py
\ No newline at end of file
diff --git a/backend/data_entry_app_backend.egg-info/dependency_links.txt b/backend/data_entry_app_backend.egg-info/dependency_links.txt
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/backend/data_entry_app_backend.egg-info/dependency_links.txt
@@ -0,0 +1 @@
+
diff --git a/backend/data_entry_app_backend.egg-info/requires.txt b/backend/data_entry_app_backend.egg-info/requires.txt
new file mode 100644
index 0000000..a9fc968
--- /dev/null
+++ b/backend/data_entry_app_backend.egg-info/requires.txt
@@ -0,0 +1,5 @@
+fastapi<1.0,>=0.115
+uvicorn[standard]<1.0,>=0.30
+sqlalchemy<3.0,>=2.0
+pydantic<3.0,>=2.8
+pytest<9.0,>=8.0
diff --git a/backend/data_entry_app_backend.egg-info/top_level.txt b/backend/data_entry_app_backend.egg-info/top_level.txt
new file mode 100644
index 0000000..b80f0bd
--- /dev/null
+++ b/backend/data_entry_app_backend.egg-info/top_level.txt
@@ -0,0 +1 @@
+app
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 0000000..25b347e
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,26 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "data-entry-app-backend"
+version = "0.1.0"
+description = "Costing platform MVP backend"
+requires-python = ">=3.11"
+dependencies = [
+ "fastapi>=0.115,<1.0",
+ "uvicorn[standard]>=0.30,<1.0",
+ "sqlalchemy>=2.0,<3.0",
+ "pydantic>=2.8,<3.0",
+ "pytest>=8.0,<9.0",
+]
+
+[tool.setuptools]
+package-dir = {"" = "."}
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["app*"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py
new file mode 100644
index 0000000..74182d6
--- /dev/null
+++ b/backend/tests/test_costing_engine.py
@@ -0,0 +1,95 @@
+from datetime import date
+
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from app.core.config import settings
+from app.db.session import Base
+from app.main import app
+from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
+from app.models.mix import Mix, MixIngredient
+from app.models.product import Product
+from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
+from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost
+
+
+def build_session() -> Session:
+ engine = create_engine("sqlite:///:memory:")
+ Base.metadata.create_all(bind=engine)
+ session = sessionmaker(bind=engine, expire_on_commit=False)()
+ return session
+
+
+def test_calculate_raw_material_cost():
+ raw_material = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
+ price = RawMaterialPriceVersion(market_value=500, waste_percentage=0.02, effective_date=date(2026, 4, 1))
+
+ result = calculate_raw_material_cost(raw_material, price)
+
+ assert result.loss_cost == 10.0
+ assert result.cost_per_unit == 510.0
+ assert result.cost_per_kg == 0.51
+
+
+def test_mix_and_product_cost_breakdown():
+ db = build_session()
+
+ maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
+ maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
+ barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
+ barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
+ db.add_all([maize, barley])
+ db.flush()
+
+ mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
+ db.add(mix)
+ db.flush()
+ db.add_all(
+ [
+ MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
+ MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100),
+ ]
+ )
+ db.add(ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0))
+ db.add(PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63))
+ db.add(FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45))
+ db.flush()
+ product = Product(
+ client_name="Specialty Feeds",
+ name="Specialty Pigeon Breeder 20kg",
+ mix_id=mix.id,
+ sale_type="standard",
+ own_bag=False,
+ unit_of_measure="20kg bag",
+ items_per_pallet=50,
+ bagging_process="standard_bagging",
+ distributor_margin=0.225,
+ wholesale_margin=0.18,
+ )
+ db.add(product)
+ db.commit()
+
+ mix_result = calculate_mix_cost(db, mix.id)
+ product_result = calculate_product_cost(db, product.id)
+
+ assert mix_result["total_mix_kg"] == 280
+ assert mix_result["mix_cost_per_kg"] == 0.5114
+ assert product_result["finished_product_delivered"] == 14.208
+ assert product_result["distributor_price"] == 18.3329
+ assert product_result["wholesale_price"] == 17.3268
+
+
+def test_root_and_login_endpoints():
+ client = TestClient(app)
+
+ root_response = client.get("/")
+ assert root_response.status_code == 200
+ assert root_response.json()["endpoints"]["login"] == "/api/auth/login"
+
+ login_response = client.post(
+ "/api/auth/login",
+ json={"email": settings.operator_email, "password": settings.operator_password},
+ )
+ assert login_response.status_code == 200
+ assert login_response.json()["email"] == settings.operator_email
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..5e79043
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1315 @@
+{
+ "name": "data-entry-app-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "data-entry-app-frontend",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^3.2.0",
+ "@sveltejs/kit": "^2.7.1",
+ "svelte": "^5.0.0",
+ "typescript": "^5.5.4",
+ "vite": "^8.0.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.127.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
+ "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
+ "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
+ "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
+ "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
+ "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
+ "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
+ "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
+ "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/adapter-auto": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.3.1.tgz",
+ "integrity": "sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "import-meta-resolve": "^4.1.0"
+ },
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.0.0"
+ }
+ },
+ "node_modules/@sveltejs/kit": {
+ "version": "2.58.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz",
+ "integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/cookie": "^0.6.0",
+ "acorn": "^8.14.1",
+ "cookie": "^0.6.0",
+ "devalue": "^5.6.4",
+ "esm-env": "^1.2.2",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.5",
+ "mrmime": "^2.0.0",
+ "set-cookie-parser": "^3.0.0",
+ "sirv": "^3.0.0"
+ },
+ "bin": {
+ "svelte-kit": "svelte-kit.js"
+ },
+ "engines": {
+ "node": ">=18.13"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.3.3 || ^6.0.0",
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz",
+ "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.2"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.46.4",
+ "vite": "^8.0.0-beta.7 || ^8.0.0"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
+ "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
+ "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/import-meta-resolve": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
+ "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.17",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
+ "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.127.0",
+ "@rolldown/pluginutils": "1.0.0-rc.17"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
+ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.55.5",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
+ "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.6.4",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.4",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.0.10",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
+ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.10",
+ "rolldown": "1.0.0-rc.17",
+ "tinyglobby": "^0.2.16"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..da446f7
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "data-entry-app-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^3.2.0",
+ "@sveltejs/kit": "^2.7.1",
+ "svelte": "^5.0.0",
+ "typescript": "^5.5.4",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/frontend/src/app.html b/frontend/src/app.html
new file mode 100644
index 0000000..fa74e64
--- /dev/null
+++ b/frontend/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
+
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..ee8a614
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,74 @@
+import { env } from '$env/dynamic/public';
+import { mockCosts, mockMixes, mockProducts, mockRawMaterials, mockScenarios } from '$lib/mock';
+import type {
+ LoginResponse,
+ Product,
+ ProductCostBreakdown,
+ RawMaterial,
+ RawMaterialCreateInput,
+ RawMaterialPriceCreateInput,
+ Scenario
+} from '$lib/types';
+
+const API_BASE_URL = env.PUBLIC_API_BASE_URL || 'http://localhost:8000';
+
+async function fetchJson(path: string, fallback: T): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}${path}`);
+ if (!response.ok) {
+ return fallback;
+ }
+ return (await response.json()) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+async function request(path: string, options: RequestInit): Promise {
+ const response = await fetch(`${API_BASE_URL}${path}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(options.headers ?? {})
+ },
+ ...options
+ });
+
+ if (!response.ok) {
+ let message = 'Request failed';
+
+ try {
+ const body = (await response.json()) as { detail?: string };
+ message = body.detail ?? message;
+ } catch {
+ message = response.statusText || message;
+ }
+
+ throw new Error(message);
+ }
+
+ return (await response.json()) as T;
+}
+
+export const api = {
+ rawMaterials: () => fetchJson('/api/raw-materials', mockRawMaterials),
+ mixes: () => fetchJson('/api/mixes', mockMixes),
+ products: () => fetchJson('/api/products', mockProducts),
+ productCosts: () => fetchJson('/api/powerbi/product-costs', mockCosts),
+ scenarios: () => fetchJson('/api/scenarios', mockScenarios),
+ dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', []),
+ login: (email: string, password: string) =>
+ request('/api/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ email, password })
+ }),
+ createRawMaterial: (payload: RawMaterialCreateInput) =>
+ request('/api/raw-materials', {
+ method: 'POST',
+ body: JSON.stringify(payload)
+ }),
+ addRawMaterialPrice: (rawMaterialId: number, payload: RawMaterialPriceCreateInput) =>
+ request(`/api/raw-materials/${rawMaterialId}/prices`, {
+ method: 'POST',
+ body: JSON.stringify(payload)
+ })
+};
diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts
new file mode 100644
index 0000000..850363c
--- /dev/null
+++ b/frontend/src/lib/mock.ts
@@ -0,0 +1,96 @@
+import type { Mix, Product, ProductCostBreakdown, RawMaterial, Scenario } from '$lib/types';
+
+export const mockRawMaterials: RawMaterial[] = [
+ {
+ id: 1,
+ name: 'Maize',
+ unit_of_measure: 'tonne',
+ kg_per_unit: 1000,
+ status: 'active',
+ current_price: {
+ market_value: 520,
+ waste_percentage: 0.02,
+ cost_per_kg: 0.5304,
+ effective_date: '2026-04-01'
+ }
+ },
+ {
+ id: 2,
+ name: 'Barley',
+ unit_of_measure: 'tonne',
+ kg_per_unit: 1000,
+ status: 'active',
+ current_price: {
+ market_value: 470,
+ waste_percentage: 0.015,
+ cost_per_kg: 0.4771,
+ effective_date: '2026-04-01'
+ }
+ }
+];
+
+export const mockMixes: Mix[] = [
+ {
+ id: 1,
+ client_name: 'Specialty Feeds',
+ name: 'Pigeon Mix',
+ status: 'active',
+ ingredients: [
+ {
+ id: 1,
+ raw_material_id: 1,
+ raw_material_name: 'Maize',
+ quantity_kg: 180,
+ cost_per_kg: 0.5304,
+ line_cost: 95.472
+ },
+ {
+ id: 2,
+ raw_material_id: 2,
+ raw_material_name: 'Barley',
+ quantity_kg: 100,
+ cost_per_kg: 0.4771,
+ line_cost: 47.71
+ }
+ ],
+ total_mix_kg: 280,
+ total_mix_cost: 143.18,
+ mix_cost_per_kg: 0.5114,
+ warnings: []
+ }
+];
+
+export const mockProducts: Product[] = [
+ {
+ id: 1,
+ name: 'Specialty Pigeon Breeder 20kg',
+ client_name: 'Specialty Feeds',
+ mix_id: 1,
+ mix_name: 'Pigeon Mix',
+ sale_type: 'standard',
+ unit_of_measure: '20kg bag',
+ distributor_margin: 0.225,
+ wholesale_margin: 0.18
+ }
+];
+
+export const mockCosts: ProductCostBreakdown[] = [
+ {
+ product_id: 1,
+ product_name: 'Specialty Pigeon Breeder 20kg',
+ finished_product_delivered: 14.208,
+ distributor_price: 18.3329,
+ wholesale_price: 17.3268,
+ warnings: []
+ }
+];
+
+export const mockScenarios: Scenario[] = [
+ {
+ id: 1,
+ name: 'Current Standard',
+ status: 'approved',
+ description: 'Baseline approved pricing',
+ overrides: {}
+ }
+];
diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts
new file mode 100644
index 0000000..f699573
--- /dev/null
+++ b/frontend/src/lib/session.ts
@@ -0,0 +1,58 @@
+import { browser } from '$app/environment';
+import { writable } from 'svelte/store';
+
+export type OperatorSession = {
+ name: string;
+ email: string;
+ role: string;
+};
+
+const STORAGE_KEY = 'data-entry-app-operator-session';
+
+function readSession(): OperatorSession | null {
+ if (!browser) {
+ return null;
+ }
+
+ const value = localStorage.getItem(STORAGE_KEY);
+ if (!value) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(value) as OperatorSession;
+ } catch {
+ localStorage.removeItem(STORAGE_KEY);
+ return null;
+ }
+}
+
+function createOperatorSessionStore() {
+ const store = writable(readSession());
+
+ if (browser) {
+ window.addEventListener('storage', (event) => {
+ if (event.key === STORAGE_KEY) {
+ store.set(readSession());
+ }
+ });
+ }
+
+ return {
+ subscribe: store.subscribe,
+ set(session: OperatorSession) {
+ if (browser) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
+ }
+ store.set(session);
+ },
+ clear() {
+ if (browser) {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ store.set(null);
+ }
+ };
+}
+
+export const operatorSession = createOperatorSessionStore();
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..ae2c813
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,121 @@
+export type RawMaterial = {
+ id: number;
+ tenant_id?: string;
+ name: string;
+ supplier?: string | null;
+ unit_of_measure: string;
+ kg_per_unit: number;
+ status: string;
+ notes?: string | null;
+ created_at?: string;
+ current_price?: {
+ id?: number;
+ market_value: number;
+ waste_percentage: number;
+ cost_per_kg: number;
+ effective_date: string;
+ status?: string;
+ notes?: string | null;
+ created_at?: string;
+ loss_cost?: number;
+ cost_per_unit?: number;
+ } | null;
+};
+
+export type MixIngredient = {
+ id: number;
+ raw_material_id: number;
+ raw_material_name: string;
+ quantity_kg: number;
+ cost_per_kg: number | null;
+ line_cost: number | null;
+ notes?: string | null;
+};
+
+export type Mix = {
+ id: number;
+ tenant_id?: string;
+ client_name: string;
+ name: string;
+ status: string;
+ version?: number;
+ notes?: string | null;
+ created_at?: string;
+ ingredients: MixIngredient[];
+ total_mix_kg: number;
+ total_mix_cost: number;
+ mix_cost_per_kg: number | null;
+ warnings: string[];
+};
+
+export type Product = {
+ id: number;
+ tenant_id?: string;
+ name: string;
+ client_name: string;
+ mix_id?: number;
+ mix_name: string;
+ sale_type: string;
+ own_bag?: boolean;
+ unit_of_measure: string;
+ items_per_pallet?: number;
+ bagging_process?: string | null;
+ distributor_margin: number | null;
+ wholesale_margin: number | null;
+ notes?: string | null;
+ created_at?: string;
+};
+
+export type ProductCostBreakdown = {
+ product_id: number;
+ product_name: string;
+ cleaned_product_cost?: number;
+ grading_cost?: number;
+ bagging_cost?: number;
+ cracking_cost?: number;
+ bag_cost?: number;
+ freight_cost?: number;
+ finished_product_delivered: number;
+ distributor_price: number | null;
+ wholesale_price: number | null;
+ warnings: string[];
+ inputs?: Record;
+};
+
+export type Scenario = {
+ id: number;
+ name: string;
+ status: string;
+ description?: string | null;
+ overrides: Record;
+};
+
+export type LoginResponse = {
+ name: string;
+ email: string;
+ role: string;
+};
+
+export type RawMaterialCreateInput = {
+ name: string;
+ supplier?: string | null;
+ unit_of_measure: string;
+ kg_per_unit: number;
+ status?: string;
+ notes?: string | null;
+ initial_price: {
+ market_value: number;
+ waste_percentage: number;
+ effective_date: string;
+ status?: string;
+ notes?: string | null;
+ };
+};
+
+export type RawMaterialPriceCreateInput = {
+ market_value: number;
+ waste_percentage: number;
+ effective_date: string;
+ status?: string;
+ notes?: string | null;
+};
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
new file mode 100644
index 0000000..5f611dd
--- /dev/null
+++ b/frontend/src/routes/+layout.svelte
@@ -0,0 +1,194 @@
+
+
+
+ Data Entry App
+
+
+
+
+
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
new file mode 100644
index 0000000..2a251ca
--- /dev/null
+++ b/frontend/src/routes/+page.svelte
@@ -0,0 +1,587 @@
+
+
+{#if !$operatorSession}
+
+
+ Costing control room
+ Sign in to manage raw materials and push updates through Mix Master automatically.
+
+ This workflow is for operators maintaining input costs. Update a raw material once, then review the refreshed
+ mix cost per kg and finished product pricing in the same session.
+
+
+
+
+ 01
+ Log in
+ Enter the operator account to unlock material maintenance and costing review.
+
+
+ 02
+ Update inputs
+ Record a new market value or waste percentage for any raw material.
+
+
+ 03
+ Review impact
+ Check Mix Master and downstream product pricing immediately after the save.
+
+
+
+
+
+
+
+
Operator login
+
Use the seeded prototype account
+
+
Backend-backed
+
+
+
+
+
+
Default email: operator@example.com
+
Default password: changeme
+
+
+
+{:else}
+
+
+
Active operator
+
Welcome back, {$operatorSession.name}.
+
Manage raw materials first, then review Mix Master and product pricing before publishing changes.
+
+ Open raw material manager
+
+{/if}
+
+
+ {#each [
+ { label: 'Raw materials', value: data.rawMaterials.length, hint: 'Maintain live input costs' },
+ { label: 'Mixes', value: data.mixes.length, hint: 'Recipes recalculated from inputs' },
+ { label: 'Products', value: data.productCosts.length, hint: 'Delivered pricing outputs' },
+ { label: 'Warnings', value: data.dataQuality.length, hint: 'Items needing review' }
+ ] as card}
+
+ {card.label}
+ {card.value}
+ {card.hint}
+
+ {/each}
+
+
+
+
+
+
+
Latest input
+
{featuredMaterial?.name ?? 'No materials loaded'}
+
+
Manage inputs
+
+
+ {#if featuredMaterial?.current_price}
+
+
+
- Market value
+ - {currency(featuredMaterial.current_price.market_value)}
+
+
+
- Waste
+ - {(featuredMaterial.current_price.waste_percentage * 100).toFixed(1)}%
+
+
+
- Cost per kg
+ - {currency(featuredMaterial.current_price.cost_per_kg, 4)}
+
+
+ {:else}
+ No active price version is available for this material.
+ {/if}
+
+
+
+
+
+
Mix master
+
{featuredMix?.name ?? 'No mixes loaded'}
+
+
Review mixes
+
+
+ {#if featuredMix}
+
+
+
- Client
+ - {featuredMix.client_name}
+
+
+
- Total mix cost
+ - {currency(featuredMix.total_mix_cost)}
+
+
+
- Cost per kg
+ - {currency(featuredMix.mix_cost_per_kg, 4)}
+
+
+ {/if}
+
+
+
+
+
+
Delivered output
+
{featuredProduct?.product_name ?? 'No product costs loaded'}
+
+
Review products
+
+
+ {#if featuredProduct}
+
+
+
- Delivered
+ - {currency(featuredProduct.finished_product_delivered)}
+
+
+
- Distributor
+ - {currency(featuredProduct.distributor_price)}
+
+
+
- Wholesale
+ - {currency(featuredProduct.wholesale_price)}
+
+
+ {/if}
+
+
+
+
+
+
+
+
Current cascade
+
Raw material updates flow straight into Mix Master
+
+
Edit materials
+
+
+
+
+
+ | Mix |
+ Ingredients |
+ Total Cost |
+ Cost/Kg |
+
+
+
+ {#each data.mixes as mix}
+
+ | {mix.name} |
+ {mix.ingredients.length} |
+ {currency(mix.total_mix_cost)} |
+ {currency(mix.mix_cost_per_kg, 4)} |
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+ | Product |
+ Delivered |
+ Distributor |
+ Wholesale |
+
+
+
+ {#each data.productCosts as row}
+
+ | {row.product_name} |
+ {currency(row.finished_product_delivered)} |
+ {currency(row.distributor_price)} |
+ {currency(row.wholesale_price)} |
+
+ {/each}
+
+
+
+
+
+
diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts
new file mode 100644
index 0000000..ccf585d
--- /dev/null
+++ b/frontend/src/routes/+page.ts
@@ -0,0 +1,20 @@
+import { api } from '$lib/api';
+
+export async function load() {
+ const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
+ api.rawMaterials(),
+ api.mixes(),
+ api.productCosts(),
+ api.scenarios(),
+ api.dataQuality()
+ ]);
+
+ return {
+ rawMaterials,
+ mixes,
+ productCosts,
+ scenarios,
+ dataQuality
+ };
+}
+
diff --git a/frontend/src/routes/mixes/+page.svelte b/frontend/src/routes/mixes/+page.svelte
new file mode 100644
index 0000000..0bc42db
--- /dev/null
+++ b/frontend/src/routes/mixes/+page.svelte
@@ -0,0 +1,96 @@
+
+
+
+ Mix Master
+ Recipes are structured as ingredient rows instead of spreadsheet columns.
+
+
+ {#each data.mixes as mix}
+
+
+
+
+
- Total Kg
+ - {mix.total_mix_kg}
+
+
+
- Total Cost
+ - ${mix.total_mix_cost.toFixed(2)}
+
+
+
- Cost/Kg
+ - {mix.mix_cost_per_kg ? `$${mix.mix_cost_per_kg.toFixed(4)}` : 'N/A'}
+
+
+ {#if mix.warnings.length}
+
+ {#each mix.warnings as warning}
+ - {warning}
+ {/each}
+
+ {/if}
+
+ {/each}
+
+
+
+
diff --git a/frontend/src/routes/mixes/+page.ts b/frontend/src/routes/mixes/+page.ts
new file mode 100644
index 0000000..b8cdd5e
--- /dev/null
+++ b/frontend/src/routes/mixes/+page.ts
@@ -0,0 +1,8 @@
+import { api } from '$lib/api';
+
+export async function load() {
+ return {
+ mixes: await api.mixes()
+ };
+}
+
diff --git a/frontend/src/routes/products/+page.svelte b/frontend/src/routes/products/+page.svelte
new file mode 100644
index 0000000..c53f928
--- /dev/null
+++ b/frontend/src/routes/products/+page.svelte
@@ -0,0 +1,59 @@
+
+
+
+ Products
+ Transparent delivered cost and pricing outputs from backend calculations.
+
+
+
+ | Product |
+ Mix |
+ Sale Type |
+ Delivered Cost |
+ Distributor |
+ Wholesale |
+
+
+
+ {#each data.products as product}
+ {@const cost = data.productCosts.find((item) => item.product_id === product.id)}
+
+ | {product.name} |
+ {product.mix_name} |
+ {product.sale_type} |
+ {cost ? `$${cost.finished_product_delivered.toFixed(2)}` : 'N/A'} |
+ {cost?.distributor_price ? `$${cost.distributor_price.toFixed(2)}` : 'N/A'} |
+ {cost?.wholesale_price ? `$${cost.wholesale_price.toFixed(2)}` : 'N/A'} |
+
+ {/each}
+
+
+
+
+
diff --git a/frontend/src/routes/products/+page.ts b/frontend/src/routes/products/+page.ts
new file mode 100644
index 0000000..e1892fe
--- /dev/null
+++ b/frontend/src/routes/products/+page.ts
@@ -0,0 +1,10 @@
+import { api } from '$lib/api';
+
+export async function load() {
+ const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]);
+ return {
+ products,
+ productCosts
+ };
+}
+
diff --git a/frontend/src/routes/raw-materials/+page.svelte b/frontend/src/routes/raw-materials/+page.svelte
new file mode 100644
index 0000000..d424452
--- /dev/null
+++ b/frontend/src/routes/raw-materials/+page.svelte
@@ -0,0 +1,633 @@
+
+
+{#if !$operatorSession}
+
+ Operator access required
+ Sign in from the homepage before managing raw materials.
+ This page is the input maintenance area for Mix Master and downstream product pricing.
+ Return to login
+
+{:else}
+
+
+ {#if successMessage}
+ {successMessage}
+ {/if}
+
+ {#if errorMessage}
+ {errorMessage}
+ {/if}
+
+
+
+
+
+
Add raw material
+
Create a new tracked input
+
+
+
+
+
+
+
+
+
+
Downstream view
+
Current mix and product snapshot
+
+
+
+
+
+ Mix Master
+
+ {#each data.mixes as mix}
+ -
+ {mix.name}
+ {currency(mix.mix_cost_per_kg, 4)} / kg
+
+ {/each}
+
+
+
+
+ Finished products
+
+ {#each data.productCosts as row}
+ -
+ {row.product_name}
+ {currency(row.finished_product_delivered)}
+
+ {/each}
+
+
+
+
+
+
+
+ {#each data.rawMaterials as material}
+ {@const impactedMixes = getImpactedMixes(material.id)}
+ {@const impactedProducts = getImpactedProducts(material.id)}
+
+
+
+
+
Tracked input
+
{material.name}
+
+ {material.supplier || 'Supplier not set'} · {material.unit_of_measure} · {material.kg_per_unit} kg per
+ unit
+
+
+
{material.status}
+
+
+
+
+
+
+ Impacted mixes
+ {#if impactedMixes.length}
+
+ {#each impactedMixes as mix}
+ -
+ {mix.name}
+ {currency(mix.mix_cost_per_kg, 4)} / kg
+
+ {/each}
+
+ {:else}
+ No mix currently references this material.
+ {/if}
+
+
+
+ Impacted products
+ {#if impactedProducts.length}
+
+ {#each impactedProducts as product}
+ -
+ {product.name}
+ {currency(product.deliveredCost?.finished_product_delivered)}
+
+ {/each}
+
+ {:else}
+ No product currently depends on this material.
+ {/if}
+
+
+
+ {/each}
+
+{/if}
+
+
diff --git a/frontend/src/routes/raw-materials/+page.ts b/frontend/src/routes/raw-materials/+page.ts
new file mode 100644
index 0000000..8b4496d
--- /dev/null
+++ b/frontend/src/routes/raw-materials/+page.ts
@@ -0,0 +1,17 @@
+import { api } from '$lib/api';
+
+export async function load() {
+ const [rawMaterials, mixes, products, productCosts] = await Promise.all([
+ api.rawMaterials(),
+ api.mixes(),
+ api.products(),
+ api.productCosts()
+ ]);
+
+ return {
+ rawMaterials,
+ mixes,
+ products,
+ productCosts
+ };
+}
diff --git a/frontend/src/routes/scenarios/+page.svelte b/frontend/src/routes/scenarios/+page.svelte
new file mode 100644
index 0000000..7c64ebb
--- /dev/null
+++ b/frontend/src/routes/scenarios/+page.svelte
@@ -0,0 +1,65 @@
+
+
+
+ Scenarios
+ Simulation workspaces for raw cost, freight, process, and margin changes.
+
+
+ {#each data.scenarios as scenario}
+
+
+ {JSON.stringify(scenario.overrides, null, 2)}
+
+ {/each}
+
+
+
+
diff --git a/frontend/src/routes/scenarios/+page.ts b/frontend/src/routes/scenarios/+page.ts
new file mode 100644
index 0000000..27e24b6
--- /dev/null
+++ b/frontend/src/routes/scenarios/+page.ts
@@ -0,0 +1,8 @@
+import { api } from '$lib/api';
+
+export async function load() {
+ return {
+ scenarios: await api.scenarios()
+ };
+}
+
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
new file mode 100644
index 0000000..69c2ea9
--- /dev/null
+++ b/frontend/svelte.config.js
@@ -0,0 +1,11 @@
+import adapter from '@sveltejs/adapter-auto';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ kit: {
+ adapter: adapter()
+ }
+};
+
+export default config;
+
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..ecf0289
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": false,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..c010b71
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,7 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [sveltekit()]
+});
+