Mix calculator

This commit is contained in:
2026-04-29 23:05:27 +12:00
parent 3f3b1d0f25
commit 5cb95266d8
28 changed files with 2943 additions and 46 deletions
+143
View File
@@ -0,0 +1,143 @@
### Mix Calculator — Clean Feature Spec
# Purpose
The Mix Calculator allows authorised users to calculate the raw materials required for a client-specific product mix, based on a selected client, product, date, and batch size.
It should also keep a history of each calculation session so users can review previous mixes without storing generated PDFs in the database.
# Core Workflow
- User opens Mix Calculator from the sidebar.
User enters/selects:
- Mix date (Defaults to today's date)
- Client
- Product - These should be populated from our existing products for that specific client
- Specify Batch size in kilograms
Total number of bags
Staff name / prepared by
App filters available products based on the selected client.
User selects a product.
App calculates:
Required raw materials
Required mix quantities
Total kilograms
Bag quantity details
User can save the session.
User can generate a polished PDF on demand.
Sidebar
# Add a dedicated sidebar item:
Mix Calculator
This should be separate from costing, product setup, or admin areas.
# Permissions
Create a dedicated permission area:
mix_calculator:view
mix_calculator:create
mix_calculator:edit
mix_calculator:delete
mix_calculator:generate_pdf
mix_calculator:view_all_sessions
# Suggested roles:
Role Access
Admin Full access
Manager Create, view all, generate PDFs
Staff Create, view own sessions, generate PDFs
Viewer View only
Database Naming
Avoid the table name mix-calculator because hyphens are awkward in SQL and code.
Use:
mix_calculator_sessions
Optional supporting tables:
clients
products
product_mixes
raw_materials
mix_calculator_session_lines
Suggested Tables
mix_calculator_sessions
Stores each calculator run.
id
session_number
client_id
product_id
mix_date
batch_size_kg
total_bags
total_kg
prepared_by_user_id
prepared_by_name
created_at
updated_at
created_by
status
notes
mix_calculator_session_lines
Stores calculated raw material outputs for each session.
id
session_id
raw_material_id
raw_material_name
required_kg
mix_percentage
unit
sort_order
This allows historical sessions to remain accurate even if the product recipe changes later.
PDF Behaviour
PDFs should not be stored in the database.
Instead:
Generate PDF on demand from the saved session.
Use the saved session and session lines as the source.
Allow download as:
MixCalculator_{Client}_{Product}_{Date}_{SessionNumber}.pdf
PDF should include:
Date
Client
Product
Batch size
Total kilograms
Total bags
Prepared by / staff name
Raw material table
Required mix table
Session number
Generated timestamp
Feature Name Options
Recommended:
Mix Calculator
Other options:
Batch Mix Calculator
Production Mix Calculator
Client Mix Calculator
Mix Session Calculator
Best fit: Mix Calculator.
Clean Requirement Summary
Build a dedicated Mix Calculator module that allows authorised users to create client-specific mix calculation sessions. Users select a date, client, product, batch size, total bags, and staff name. The system calculates required raw materials and mix quantities, saves the session history, and allows users to generate a polished PDF on demand. PDFs should not be stored in the database; they should be generated dynamically from the saved session data.
+95
View File
@@ -0,0 +1,95 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.schemas.mix_calculator import (
MixCalculatorOptionsRead,
MixCalculatorPreviewRead,
MixCalculatorSessionCreate,
MixCalculatorSessionRead,
MixCalculatorSessionSummaryRead,
MixCalculatorSessionUpdate,
)
from app.services.mix_calculator_service import (
build_mix_calculator_options,
calculate_mix_calculator_preview,
serialize_mix_calculator_session,
create_mix_calculator_session,
get_mix_calculator_session,
update_mix_calculator_session,
list_mix_calculator_sessions,
can_view_all_mix_calculator_sessions,
)
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
@router.get("/options", response_model=MixCalculatorOptionsRead)
def mix_calculator_options(
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
db: Session = Depends(get_db),
):
return build_mix_calculator_options(db, tenant_id=session.tenant_id or "")
@router.get("", response_model=list[MixCalculatorSessionSummaryRead])
def mix_calculator_sessions(
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
db: Session = Depends(get_db),
):
return list_mix_calculator_sessions(db, auth_session=session)
@router.post("/preview", response_model=MixCalculatorPreviewRead)
def preview_mix_calculator_session(
payload: MixCalculatorSessionCreate,
session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")),
db: Session = Depends(get_db),
):
try:
return calculate_mix_calculator_preview(db, tenant_id=session.tenant_id or "", payload=payload)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post("", response_model=MixCalculatorSessionRead, status_code=status.HTTP_201_CREATED)
def create_saved_mix_calculator_session(
payload: MixCalculatorSessionCreate,
session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")),
db: Session = Depends(get_db),
):
try:
return create_mix_calculator_session(db, auth_session=session, payload=payload)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.get("/{session_id}", response_model=MixCalculatorSessionRead)
def read_mix_calculator_session(
session_id: int,
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
db: Session = Depends(get_db),
):
session_record = get_mix_calculator_session(db, auth_session=session, session_id=session_id)
if session_record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mix calculator session not found")
return serialize_mix_calculator_session(session_record, session)
@router.patch("/{session_id}", response_model=MixCalculatorSessionRead)
def patch_mix_calculator_session(
session_id: int,
payload: MixCalculatorSessionUpdate,
session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")),
db: Session = Depends(get_db),
):
session_record = get_mix_calculator_session(db, auth_session=session, session_id=session_id)
if session_record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mix calculator session not found")
if not can_view_all_mix_calculator_sessions(session) and session_record.prepared_by_user_id != session.user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own mix calculator sessions")
try:
return update_mix_calculator_session(db, auth_session=session, session_record=session_record, payload=payload)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+33
View File
@@ -15,6 +15,8 @@ TENANT_TABLES = {
"raw_material_price_versions": None,
"mixes": None,
"mix_ingredients": None,
"mix_calculator_sessions": None,
"mix_calculator_session_lines": None,
"products": None,
"scenarios": None,
"costing_results": None,
@@ -230,6 +232,37 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]:
"""
),
),
(
"mix_calculator_sessions",
text(
"""
UPDATE mix_calculator_sessions
SET tenant_id = COALESCE(
(
SELECT products.tenant_id
FROM products
WHERE products.id = mix_calculator_sessions.product_id
),
:default_tenant
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
),
(
"mix_calculator_session_lines",
text(
"""
UPDATE mix_calculator_session_lines
SET tenant_id = (
SELECT mix_calculator_sessions.tenant_id
FROM mix_calculator_sessions
WHERE mix_calculator_sessions.id = mix_calculator_session_lines.session_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
),
(
"scenarios",
text(
+3
View File
@@ -14,6 +14,7 @@ import uvicorn
from app.api.auth import router as auth_router
from app.api.client_access import router as client_access_router
from app.api.mix_calculator import router as mix_calculator_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
@@ -74,6 +75,7 @@ app.include_router(auth_router)
app.include_router(client_access_router)
app.include_router(raw_materials_router)
app.include_router(mixes_router)
app.include_router(mix_calculator_router)
app.include_router(products_router)
app.include_router(scenarios_router)
app.include_router(powerbi_router)
@@ -95,6 +97,7 @@ def root():
"admin_login": "/api/auth/admin/login",
"raw_materials": "/api/raw-materials",
"mixes": "/api/mixes",
"mix_calculator": "/api/mix-calculator",
"products": "/api/products",
"scenarios": "/api/scenarios",
"client_access": "/api/client-access",
+3
View File
@@ -1,5 +1,6 @@
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
@@ -14,6 +15,8 @@ __all__ = [
"CostingResult",
"FreightCostRule",
"Mix",
"MixCalculatorSession",
"MixCalculatorSessionLine",
"MixIngredient",
"PackagingCostRule",
"ProcessCostRule",
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class MixCalculatorSession(Base):
__tablename__ = "mix_calculator_sessions"
__table_args__ = (UniqueConstraint("tenant_id", "session_number", name="uq_mix_calculator_session_number"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
session_number: Mapped[str] = mapped_column(String(32), index=True)
client_name: Mapped[str] = mapped_column(String(255), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
product_name: Mapped[str] = mapped_column(String(255))
mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"), index=True)
mix_name: Mapped[str] = mapped_column(String(255))
mix_date: Mapped[date] = mapped_column(Date, index=True)
batch_size_kg: Mapped[float] = mapped_column(Float)
total_bags: Mapped[float] = mapped_column(Float)
total_kg: Mapped[float] = mapped_column(Float)
product_unit_of_measure: Mapped[str] = mapped_column(String(64))
product_unit_size_kg: Mapped[float] = mapped_column(Float)
prepared_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True, index=True)
prepared_by_name: Mapped[str] = mapped_column(String(255))
created_by: Mapped[str] = mapped_column(String(255))
status: Mapped[str] = mapped_column(String(32), default="saved")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
lines: Mapped[list["MixCalculatorSessionLine"]] = relationship(
back_populates="session",
cascade="all, delete-orphan",
order_by="MixCalculatorSessionLine.sort_order",
)
class MixCalculatorSessionLine(Base):
__tablename__ = "mix_calculator_session_lines"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
session_id: Mapped[int] = mapped_column(ForeignKey("mix_calculator_sessions.id"), index=True)
raw_material_id: Mapped[int | None] = mapped_column(nullable=True)
raw_material_name: Mapped[str] = mapped_column(String(255))
required_kg: Mapped[float] = mapped_column(Float)
mix_percentage: Mapped[float] = mapped_column(Float)
unit: Mapped[str] = mapped_column(String(64))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
+103
View File
@@ -0,0 +1,103 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
class MixCalculatorProductOptionRead(BaseModel):
product_id: int
client_name: str
product_name: str
mix_id: int
mix_name: str
unit_of_measure: str
unit_size_kg: float
mix_total_kg: float
class MixCalculatorOptionsRead(BaseModel):
clients: list[str]
products: list[MixCalculatorProductOptionRead]
class MixCalculatorSessionLineRead(BaseModel):
id: int | None = None
raw_material_id: int | None
raw_material_name: str
required_kg: float
mix_percentage: float
unit: str
sort_order: int
class MixCalculatorSessionBase(BaseModel):
mix_date: date
client_name: str
product_id: int
batch_size_kg: float = Field(gt=0)
prepared_by_name: str = Field(min_length=1, max_length=255)
status: str = "saved"
notes: str | None = None
class MixCalculatorSessionCreate(MixCalculatorSessionBase):
pass
class MixCalculatorSessionUpdate(BaseModel):
mix_date: date | None = None
client_name: str | None = None
product_id: int | None = None
batch_size_kg: float | None = Field(default=None, gt=0)
prepared_by_name: str | None = Field(default=None, min_length=1, max_length=255)
status: str | None = None
notes: str | None = None
class MixCalculatorPreviewRead(BaseModel):
client_name: str
product_id: int
product_name: str
mix_id: int
mix_name: str
mix_date: date
batch_size_kg: float
total_bags: float
total_kg: float
product_unit_of_measure: str
product_unit_size_kg: float
prepared_by_name: str
status: str
notes: str | None
warnings: list[str]
lines: list[MixCalculatorSessionLineRead]
class MixCalculatorSessionSummaryRead(BaseModel):
id: int
tenant_id: str
session_number: str
client_name: str
product_id: int
product_name: str
mix_id: int
mix_name: str
mix_date: date
batch_size_kg: float
total_bags: float
total_kg: float
product_unit_of_measure: str
product_unit_size_kg: float
prepared_by_user_id: int | None
prepared_by_name: str
created_by: str
status: str
notes: str | None
created_at: datetime
updated_at: datetime
warnings: list[str]
is_owner: bool
model_config = ConfigDict(from_attributes=True)
class MixCalculatorSessionRead(MixCalculatorSessionSummaryRead):
lines: list[MixCalculatorSessionLineRead]
+2 -2
View File
@@ -72,8 +72,8 @@ def seed_client_access(db):
)
enabled_feature_map = {
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export", "client_access"},
"loft-grains": {"dashboard", "products", "powerbi_export"},
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"},
"loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"},
}
for client in (specialty, loft):
+56 -3
View File
@@ -17,6 +17,7 @@ MODULE_CATALOG = (
("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"),
("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"),
("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"),
("mix_calculator", "Mix Calculator", "production", "Create and review client-specific mix calculation sessions"),
("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
@@ -43,6 +44,7 @@ def client_access_query() -> Select[tuple[ClientAccount]]:
def list_client_accounts(db: Session) -> list[ClientAccount]:
ensure_client_user_module_permissions(db)
ensure_client_feature_access(db)
return db.scalars(client_access_query()).all()
@@ -50,10 +52,20 @@ def get_client_user_by_email(db: Session, *, email: str, tenant_id: str | None =
statement = select(ClientUser).where(ClientUser.email == email)
if tenant_id:
statement = statement.where(ClientUser.tenant_id == tenant_id)
user = db.scalar(
statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc())
)
if user is None:
return None
if ensure_user_module_permissions(db, user):
db.commit()
return db.scalar(
statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc())
)
return user
def module_access_map(user: ClientUser) -> dict[str, str]:
return {permission.module_key: permission.access_level for permission in user.module_permissions}
@@ -66,13 +78,15 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
def default_access_level_for_role(role: str, module_key: str) -> str:
normalized = role.strip().lower()
if normalized == "superadmin":
return "manage" if module_key == "client_access" else "edit"
return "manage" if module_key in {"client_access", "mix_calculator"} else "edit"
if normalized == "admin":
if module_key == "mix_calculator":
return "manage"
return "edit" if module_key != "client_access" else "none"
if normalized == "operator":
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "products", "scenarios"} else "none"
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios"} else "none"
if normalized == "viewer":
return "view" if module_key in {"dashboard", "products", "powerbi_export"} else "none"
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export"} else "none"
return "none"
@@ -106,6 +120,45 @@ def ensure_client_user_module_permissions(db: Session) -> None:
db.commit()
def ensure_client_feature_access(db: Session) -> None:
clients = db.scalars(
select(ClientAccount).options(
selectinload(ClientAccount.users).selectinload(ClientUser.module_permissions),
selectinload(ClientAccount.features),
)
).all()
changed = False
for client in clients:
existing_feature_keys = {feature.feature_key for feature in client.features}
permission_levels: dict[str, str] = {}
for user in client.users:
for permission in user.module_permissions:
current_level = permission_levels.get(permission.module_key, "none")
if ACCESS_LEVEL_ORDER.get(permission.access_level, 0) > ACCESS_LEVEL_ORDER.get(current_level, 0):
permission_levels[permission.module_key] = permission.access_level
for feature_key, feature_name, feature_group, description in MODULE_CATALOG:
if feature_key in existing_feature_keys:
continue
db.add(
ClientFeatureAccess(
tenant_id=client.tenant_id,
client_account_id=client.id,
feature_key=feature_key,
feature_name=feature_name,
feature_group=feature_group,
description=description,
enabled=has_access_level(permission_levels.get(feature_key), "view"),
)
)
changed = True
if changed:
db.commit()
def serialize_client_user(user: ClientUser) -> dict:
return {
"id": user.id,
+2 -2
View File
@@ -177,7 +177,7 @@ def _apply_margin(cost: float, margin: float | None) -> float | None:
return round(cost / (1 - margin), 4)
def _extract_unit_quantity_kg(unit_of_measure: str) -> float:
def extract_unit_quantity_kg(unit_of_measure: str) -> float:
normalized = unit_of_measure.strip().lower()
if normalized == "tonne":
return 1000.0
@@ -199,7 +199,7 @@ def calculate_product_cost(db: Session, product_id: int, overrides: dict | None
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)
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)
@@ -0,0 +1,311 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import AuthSession
from app.models.mix import Mix, MixIngredient
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.product import Product
from app.schemas.mix_calculator import MixCalculatorSessionCreate, MixCalculatorSessionUpdate
from app.services.costing_engine import extract_unit_quantity_kg
def can_view_all_mix_calculator_sessions(session: AuthSession) -> bool:
return session.client_role in {"superadmin", "admin"}
def _build_session_access_query(session: AuthSession):
query = select(MixCalculatorSession).where(MixCalculatorSession.tenant_id == session.tenant_id)
if can_view_all_mix_calculator_sessions(session):
return query
return query.where(MixCalculatorSession.prepared_by_user_id == session.user_id)
def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None:
return db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == tenant_id)
.options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
)
def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_measure: str) -> str | None:
rounded_bags = round(total_bags)
if abs(total_bags - rounded_bags) < 1e-9:
return None
return (
f"Batch size {batch_size_kg:g}kg produces {total_bags:.2f} bags for {unit_of_measure}. "
"This is not a whole-bag quantity."
)
def calculate_mix_calculator_preview(
db: Session,
*,
tenant_id: str,
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
):
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
product = _load_product_for_calculation(db, tenant_id, int(values["product_id"]))
if product is None:
raise ValueError("Product not found")
if product.client_name != values["client_name"]:
raise ValueError("Selected product does not belong to the chosen client")
if product.mix is None:
raise ValueError("Product mix is not configured")
source_total_kg = round(sum(ingredient.quantity_kg for ingredient in product.mix.ingredients), 4)
if source_total_kg <= 0:
raise ValueError("Product mix has no source kilograms to scale")
batch_size_kg = float(values["batch_size_kg"])
scale_factor = batch_size_kg / source_total_kg
unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure)
total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0
warnings: list[str] = []
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure)
if bag_warning:
warnings.append(bag_warning)
lines = []
for index, ingredient in enumerate(product.mix.ingredients, start=1):
mix_percentage = round((ingredient.quantity_kg / source_total_kg) * 100, 4)
required_kg = round(ingredient.quantity_kg * scale_factor, 4)
raw_material = ingredient.raw_material
lines.append(
{
"raw_material_id": raw_material.id if raw_material is not None else ingredient.raw_material_id,
"raw_material_name": raw_material.name if raw_material is not None else f"Raw material {ingredient.raw_material_id}",
"required_kg": required_kg,
"mix_percentage": mix_percentage,
"unit": raw_material.unit_of_measure if raw_material is not None else "kg",
"sort_order": index,
}
)
return {
"client_name": product.client_name,
"product_id": product.id,
"product_name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name,
"mix_date": values["mix_date"],
"batch_size_kg": round(batch_size_kg, 4),
"total_bags": total_bags,
"total_kg": round(batch_size_kg, 4),
"product_unit_of_measure": product.unit_of_measure,
"product_unit_size_kg": round(unit_size_kg, 4),
"prepared_by_name": values["prepared_by_name"],
"status": values.get("status") or "saved",
"notes": values.get("notes"),
"warnings": warnings,
"lines": lines,
}
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id)
.options(selectinload(Product.mix).selectinload(Mix.ingredients))
.order_by(Product.client_name, Product.name)
).all()
product_rows = []
clients = sorted({product.client_name for product in products})
for product in products:
mix_total_kg = round(sum(ingredient.quantity_kg for ingredient in (product.mix.ingredients if product.mix else [])), 4)
product_rows.append(
{
"product_id": product.id,
"client_name": product.client_name,
"product_name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"unit_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"mix_total_kg": mix_total_kg,
}
)
return {"clients": clients, "products": product_rows}
def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_session: AuthSession) -> dict:
total_bags = round(session_record.total_bags, 4)
warnings: list[str] = []
bag_warning = _fractional_bag_warning(session_record.batch_size_kg, total_bags, session_record.product_unit_of_measure)
if bag_warning:
warnings.append(bag_warning)
return {
"id": session_record.id,
"tenant_id": session_record.tenant_id,
"session_number": session_record.session_number,
"client_name": session_record.client_name,
"product_id": session_record.product_id,
"product_name": session_record.product_name,
"mix_id": session_record.mix_id,
"mix_name": session_record.mix_name,
"mix_date": session_record.mix_date,
"batch_size_kg": round(session_record.batch_size_kg, 4),
"total_bags": total_bags,
"total_kg": round(session_record.total_kg, 4),
"product_unit_of_measure": session_record.product_unit_of_measure,
"product_unit_size_kg": round(session_record.product_unit_size_kg, 4),
"prepared_by_user_id": session_record.prepared_by_user_id,
"prepared_by_name": session_record.prepared_by_name,
"created_by": session_record.created_by,
"status": session_record.status,
"notes": session_record.notes,
"created_at": session_record.created_at,
"updated_at": session_record.updated_at,
"warnings": warnings,
"is_owner": session_record.prepared_by_user_id == auth_session.user_id,
"lines": [
{
"id": line.id,
"raw_material_id": line.raw_material_id,
"raw_material_name": line.raw_material_name,
"required_kg": round(line.required_kg, 4),
"mix_percentage": round(line.mix_percentage, 4),
"unit": line.unit,
"sort_order": line.sort_order,
}
for line in session_record.lines
],
}
def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession) -> list[dict]:
sessions = db.scalars(
_build_session_access_query(auth_session)
.options(selectinload(MixCalculatorSession.lines))
.order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc())
).all()
return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions]
def get_mix_calculator_session(db: Session, *, auth_session: AuthSession, session_id: int) -> MixCalculatorSession | None:
return db.scalar(
_build_session_access_query(auth_session)
.where(MixCalculatorSession.id == session_id)
.options(selectinload(MixCalculatorSession.lines))
)
def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str:
prefix = f"HPP-{mix_date.strftime('%Y%m%d')}-"
existing = db.scalars(
select(MixCalculatorSession.session_number)
.where(
MixCalculatorSession.tenant_id == tenant_id,
MixCalculatorSession.mix_date == mix_date,
MixCalculatorSession.session_number.like(f"{prefix}%"),
)
).all()
sequence = 1
if existing:
sequence = max(int(value.rsplit("-", 1)[-1]) for value in existing) + 1
return f"{prefix}{sequence:04d}"
def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict:
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload)
session_record = MixCalculatorSession(
tenant_id=auth_session.tenant_id or "default",
session_number=_next_session_number(db, tenant_id=auth_session.tenant_id or "default", mix_date=payload.mix_date),
client_name=preview["client_name"],
product_id=preview["product_id"],
product_name=preview["product_name"],
mix_id=preview["mix_id"],
mix_name=preview["mix_name"],
mix_date=preview["mix_date"],
batch_size_kg=preview["batch_size_kg"],
total_bags=preview["total_bags"],
total_kg=preview["total_kg"],
product_unit_of_measure=preview["product_unit_of_measure"],
product_unit_size_kg=preview["product_unit_size_kg"],
prepared_by_user_id=auth_session.user_id,
prepared_by_name=preview["prepared_by_name"],
created_by=auth_session.email,
status=preview["status"],
notes=preview["notes"],
)
session_record.lines = [
MixCalculatorSessionLine(
tenant_id=auth_session.tenant_id or "default",
raw_material_id=line["raw_material_id"],
raw_material_name=line["raw_material_name"],
required_kg=line["required_kg"],
mix_percentage=line["mix_percentage"],
unit=line["unit"],
sort_order=line["sort_order"],
)
for line in preview["lines"]
]
db.add(session_record)
db.commit()
db.refresh(session_record)
db.refresh(session_record, attribute_names=["lines"])
return serialize_mix_calculator_session(session_record, auth_session)
def update_mix_calculator_session(
db: Session,
*,
auth_session: AuthSession,
session_record: MixCalculatorSession,
payload: MixCalculatorSessionUpdate,
) -> dict:
merged_values = {
"mix_date": session_record.mix_date,
"client_name": session_record.client_name,
"product_id": session_record.product_id,
"batch_size_kg": session_record.batch_size_kg,
"prepared_by_name": session_record.prepared_by_name,
"status": session_record.status,
"notes": session_record.notes,
}
merged_values.update(payload.model_dump(exclude_unset=True))
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=merged_values)
session_record.client_name = preview["client_name"]
session_record.product_id = preview["product_id"]
session_record.product_name = preview["product_name"]
session_record.mix_id = preview["mix_id"]
session_record.mix_name = preview["mix_name"]
session_record.mix_date = preview["mix_date"]
session_record.batch_size_kg = preview["batch_size_kg"]
session_record.total_bags = preview["total_bags"]
session_record.total_kg = preview["total_kg"]
session_record.product_unit_of_measure = preview["product_unit_of_measure"]
session_record.product_unit_size_kg = preview["product_unit_size_kg"]
session_record.prepared_by_name = preview["prepared_by_name"]
session_record.status = preview["status"]
session_record.notes = preview["notes"]
session_record.lines.clear()
session_record.lines.extend(
[
MixCalculatorSessionLine(
tenant_id=auth_session.tenant_id or "default",
raw_material_id=line["raw_material_id"],
raw_material_name=line["raw_material_name"],
required_kg=line["required_kg"],
mix_percentage=line["mix_percentage"],
unit=line["unit"],
sort_order=line["sort_order"],
)
for line in preview["lines"]
]
)
db.commit()
db.refresh(session_record)
db.refresh(session_record, attribute_names=["lines"])
return serialize_mix_calculator_session(session_record, auth_session)
+113 -3
View File
@@ -11,11 +11,13 @@ from app.db.session import Base
from app.main import app
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser
from app.schemas.mix_calculator import MixCalculatorSessionCreate
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost
from app.services.mix_calculator_service import calculate_mix_calculator_preview
def build_session() -> Session:
@@ -86,9 +88,62 @@ def test_mix_and_product_cost_breakdown():
assert product_result["wholesale_price"] == 17.3268
def test_root_and_login_endpoints():
client = TestClient(app)
def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
db = build_session()
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add_all([maize, barley])
db.flush()
mix = Mix(tenant_id="specialty-feeds", client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
db.add(mix)
db.flush()
db.add_all(
[
MixIngredient(tenant_id="specialty-feeds", mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
MixIngredient(tenant_id="specialty-feeds", mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100),
]
)
db.flush()
product = Product(
tenant_id="specialty-feeds",
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",
)
db.add(product)
db.commit()
preview = calculate_mix_calculator_preview(
db,
tenant_id="specialty-feeds",
payload=MixCalculatorSessionCreate(
mix_date=date(2026, 4, 29),
client_name="Specialty Feeds",
product_id=product.id,
batch_size_kg=550,
prepared_by_name="Shift A",
notes="Mid-morning run",
),
)
assert preview["batch_size_kg"] == 550
assert preview["total_bags"] == 27.5
assert preview["lines"][0]["required_kg"] == 353.5714
assert preview["lines"][1]["required_kg"] == 196.4286
assert len(preview["warnings"]) == 1
assert "not a whole-bag quantity" in preview["warnings"][0]
def test_root_and_login_endpoints():
with TestClient(app) as client:
root_response = client.get("/")
assert root_response.status_code == 200
assert root_response.json()["endpoints"]["client_login"] == "/api/auth/client/login"
@@ -220,6 +275,61 @@ def test_client_access_endpoints():
assert len(superadmin_access_response.json()) == 1
def test_mix_calculator_endpoints_respect_owner_visibility():
with TestClient(app) as client:
superadmin_login = client.post(
"/api/auth/client/login",
json={"email": settings.client_email, "password": settings.client_password},
)
assert superadmin_login.status_code == 200
superadmin_headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"}
options_response = client.get("/api/mix-calculator/options", headers=superadmin_headers)
assert options_response.status_code == 200
assert options_response.json()["products"][0]["product_name"] == "Hunter Orchard Blend 20kg"
create_response = client.post(
"/api/mix-calculator",
json={
"mix_date": "2026-04-29",
"client_name": "Hunter Premium Produce",
"product_id": 1,
"batch_size_kg": 560,
"prepared_by_name": "Amelia Hart",
"notes": "Morning production run",
},
headers=superadmin_headers,
)
assert create_response.status_code == 201
created = create_response.json()
assert created["session_number"].startswith("HPP-20260429-")
assert created["total_bags"] == 28
assert created["lines"][0]["required_kg"] == 360
patch_response = client.patch(
f"/api/mix-calculator/{created['id']}",
json={"batch_size_kg": 550},
headers=superadmin_headers,
)
assert patch_response.status_code == 200
assert patch_response.json()["total_bags"] == 27.5
assert len(patch_response.json()["warnings"]) == 1
operator_login = client.post(
"/api/auth/client/login",
json={"email": "ethan.cole@hunterpremiumproduce.example", "password": settings.client_password},
)
assert operator_login.status_code == 200
operator_headers = {"Authorization": f"Bearer {operator_login.json()['token']}"}
operator_list_response = client.get("/api/mix-calculator", headers=operator_headers)
assert operator_list_response.status_code == 200
assert operator_list_response.json() == []
operator_detail_response = client.get(f"/api/mix-calculator/{created['id']}", headers=operator_headers)
assert operator_detail_response.status_code == 404
def test_module_permission_blocks_client_module_access():
with TestClient(app) as client:
admin_login_response = client.post(
@@ -229,7 +339,7 @@ def test_module_permission_blocks_client_module_access():
admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"}
access_response = client.get("/api/client-access", headers=admin_headers)
first_client = access_response.json()[0]
first_user = first_client["users"][0]
first_user = next(user for user in first_client["users"] if user["email"] == settings.client_email)
permission = next(
permission for permission in first_user["module_permissions"] if permission["module_key"] == "raw_materials"
+28
View File
@@ -4,6 +4,8 @@ import {
mockClientAccess,
mockClientAccessExport,
mockCosts,
mockMixCalculatorOptions,
mockMixCalculatorSessions,
mockMixes,
mockProducts,
mockRawMaterials,
@@ -16,6 +18,11 @@ import type {
ClientUserModulePermission,
ClientUserUpdateInput,
LoginResponse,
MixCalculatorCreateInput,
MixCalculatorOptions,
MixCalculatorPreview,
MixCalculatorSession,
MixCalculatorUpdateInput,
Mix,
MixCreateInput,
MixIngredientUpdateInput,
@@ -128,6 +135,27 @@ export const api = {
rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher),
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
createMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
request<MixCalculatorSession>('/api/mix-calculator', {
method: 'POST',
body: JSON.stringify(payload)
}, 'client'),
updateMixCalculatorSession: (sessionId: number, payload: MixCalculatorUpdateInput) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => fetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
productCosts: (fetcher?: ApiFetch) =>
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
+62 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { api } from '$lib/api';
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
@@ -23,6 +24,7 @@
};
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' };
const mixCalculatorItem: NavItem = { href: '/mix-calculator', label: 'Mix Calculator', shortLabel: 'MC', moduleKey: 'mix_calculator' };
const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' },
@@ -30,7 +32,7 @@
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' }
];
const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', moduleKey: 'client_access' };
const navigation = [dashboardItem, ...workingDocumentItems, accessControlItem];
const navigation = [dashboardItem, mixCalculatorItem, ...workingDocumentItems, accessControlItem];
const footerLinks = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
@@ -62,6 +64,18 @@
description: 'Start a new costing worksheet for Hunter Premium Produce.',
keywords: 'new mix create worksheet hunter premium produce formula'
},
{
href: '/mix-calculator',
label: 'Open Mix Calculator',
description: 'Review saved production sessions and batch calculations.',
keywords: 'mix calculator production sessions batch bags client product'
},
{
href: '/mix-calculator/new',
label: 'Create Mix Calculation',
description: 'Run a new client-specific mix calculation session.',
keywords: 'new mix calculator session client batch size product bags print'
},
{
href: '/products',
label: 'Open Products',
@@ -105,6 +119,11 @@
? workingDocumentItems
: workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey))
);
const visibleMixCalculatorItem = $derived(
!$clientSession || !mixCalculatorItem.moduleKey || hasModuleAccess($clientSession, mixCalculatorItem.moduleKey)
? mixCalculatorItem
: null
);
const visibleFooterLinks = $derived([
...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
@@ -112,7 +131,11 @@
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }])
]);
const primaryBottomNavigation = $derived(
[...(visibleDashboardItem ? [visibleDashboardItem] : []), ...visibleWorkingDocumentItems.slice(0, 3)]
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
);
function matchesRoute(href: string, pathname: string) {
@@ -124,11 +147,17 @@
}
function pageDescription(pathname: string) {
if (pathname.startsWith('/mix-calculator/')) {
return 'Review a saved mix calculation session and prepare a printable output';
}
const descriptions: Record<string, string> = {
'/': 'Hunter Premium Produce client workspace',
'/raw-materials': 'Review source input costs and downstream exposure',
'/mixes': 'Browse saved mix worksheets and costing outputs',
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
'/mix-calculator': 'Create and review client-specific mix calculation sessions',
'/mix-calculator/new': 'Create a new client-specific mix calculation session',
'/products': 'Track delivered product pricing and margin views',
'/settings': 'Review your workspace profile and application settings',
'/scenarios': 'Compare alternate pricing and production assumptions',
@@ -210,10 +239,18 @@
restoredToken = token;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredToken === token) {
api.clientSession()
.then((session) => {
restoredToken = session.token;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredToken = null;
clientSession.clear();
})
.finally(() => {
isRestoringSession = false;
}
});
});
@@ -319,6 +356,13 @@
<span>{visibleDashboardItem.label}</span>
</a>
{/if}
{#if visibleMixCalculatorItem}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href}>
<span class="nav-icon">{visibleMixCalculatorItem.shortLabel}</span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
</nav>
<div class="nav-group" aria-label="Working documents" hidden={!visibleWorkingDocumentItems.length}>
@@ -396,6 +440,8 @@
<div class="menu-panel">
<a href="/mixes">Open mix costing</a>
<a href="/mixes/new">Create mix worksheet</a>
<a href="/mix-calculator">Open mix calculator</a>
<a href="/mix-calculator/new">Create mix session</a>
<a href="/products">Review delivered pricing</a>
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
</div>
@@ -568,6 +614,13 @@
</a>
{/if}
{#if visibleMixCalculatorItem}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">{visibleMixCalculatorItem.shortLabel}</span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
<div class="drawer-group" hidden={!visibleWorkingDocumentItems.length}>
<button
aria-controls="drawer-working-documents-nav"
@@ -597,6 +650,10 @@
<span class="nav-icon">NW</span>
<span>Create mix worksheet</span>
</a>
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}>
<span class="nav-icon">MC</span>
<span>Create mix session</span>
</a>
<button type="button" onclick={openSettings}>
<span class="nav-icon muted">ST</span>
<span>Change settings</span>
@@ -0,0 +1,321 @@
<script lang="ts">
import type { MixCalculatorSession } from '$lib/types';
let { session }: { session: MixCalculatorSession } = $props();
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: undefined
}).format(new Date(value));
}
function formatTimestamp(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function formatNumber(value: number, digits = 2) {
return value.toFixed(digits);
}
const printableTitle = $derived(
`MixCalculator_${session.client_name}_${session.product_name}_${session.mix_date}_${session.session_number}`.replace(/[^\w.-]+/g, '_')
);
</script>
<svelte:head>
<title>{printableTitle}</title>
</svelte:head>
<section class="print-page">
<div class="print-toolbar">
<a class="secondary-button" href={`/mix-calculator/${session.id}`}>Back to session</a>
<button class="primary-button" type="button" onclick={() => window.print()}>Print / Save PDF</button>
</div>
<article class="sheet">
<header class="sheet-header">
<div>
<p class="eyebrow">Mix Calculator</p>
<h1>{session.session_number}</h1>
<p>Generated from the saved session snapshot without re-reading live product or recipe data.</p>
</div>
<div class="sheet-meta">
<div>
<span>Generated</span>
<strong>{formatTimestamp(new Date().toISOString())}</strong>
</div>
<div>
<span>Status</span>
<strong>{session.status}</strong>
</div>
</div>
</header>
<section class="summary-grid">
<div>
<span>Date</span>
<strong>{formatDate(session.mix_date)}</strong>
</div>
<div>
<span>Client</span>
<strong>{session.client_name}</strong>
</div>
<div>
<span>Product</span>
<strong>{session.product_name}</strong>
</div>
<div>
<span>Mix source</span>
<strong>{session.mix_name}</strong>
</div>
<div>
<span>Batch size</span>
<strong>{formatNumber(session.batch_size_kg, 2)}kg</strong>
</div>
<div>
<span>Total bags</span>
<strong>{formatNumber(session.total_bags, 2)}</strong>
</div>
<div>
<span>Total kilograms</span>
<strong>{formatNumber(session.total_kg, 2)}kg</strong>
</div>
<div>
<span>Prepared by</span>
<strong>{session.prepared_by_name}</strong>
</div>
</section>
{#if session.notes}
<section class="notes-card">
<h2>Session notes</h2>
<p>{session.notes}</p>
</section>
{/if}
{#if session.warnings.length}
<section class="warning-card">
<h2>Warnings</h2>
{#each session.warnings as warning}
<p>{warning}</p>
{/each}
</section>
{/if}
<section class="table-card">
<div class="table-header">
<h2>Required raw materials</h2>
<span>{session.product_unit_of_measure} · {formatNumber(session.product_unit_size_kg, 2)}kg per unit</span>
</div>
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each session.lines as line}
<tr>
<td>{line.raw_material_name}</td>
<td>{formatNumber(line.mix_percentage, 2)}%</td>
<td>{formatNumber(line.required_kg, 2)}kg</td>
<td>{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</article>
</section>
<style>
h1,
h2,
p {
margin: 0;
}
.print-page {
display: grid;
gap: 1rem;
}
.print-toolbar {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.78rem 0.96rem;
border-radius: 0.9rem;
border: 1px solid var(--line-strong);
font-weight: 600;
cursor: pointer;
}
.primary-button {
border: none;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #fff;
}
.secondary-button {
background: #fff;
color: #304038;
}
.sheet {
width: min(960px, 100%);
margin: 0 auto;
padding: 2rem;
border: 1px solid var(--line);
border-radius: 1.5rem;
background: #fff;
box-shadow: var(--shadow);
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sheet-header {
display: flex;
justify-content: space-between;
gap: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--line);
}
.sheet-header h1 {
margin: 0.3rem 0 0.45rem;
font-size: clamp(2rem, 4vw, 2.6rem);
}
.sheet-header p:last-child,
.sheet-meta span,
.summary-grid span,
.table-header span {
color: var(--muted);
}
.sheet-meta {
min-width: 14rem;
display: grid;
gap: 0.9rem;
}
.sheet-meta div,
.summary-grid div {
display: grid;
gap: 0.16rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
padding: 1.35rem 0;
}
.notes-card,
.warning-card,
.table-card {
margin-top: 1rem;
}
.notes-card,
.warning-card {
padding: 1rem;
border-radius: 1rem;
}
.notes-card {
background: var(--panel-soft);
}
.warning-card {
background: #fff6e6;
color: #8b5b1e;
}
.warning-card h2,
.notes-card h2,
.table-header h2 {
margin-bottom: 0.45rem;
font-size: 1rem;
}
.table-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.88rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@media (max-width: 900px) {
.sheet-header,
.table-header {
flex-direction: column;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media print {
:global(body) {
background: #fff;
}
.print-toolbar {
display: none;
}
.sheet {
width: 100%;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
box-shadow: none;
}
}
</style>
@@ -0,0 +1,763 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { clientSession, hasModuleAccess } from '$lib/session';
import type {
MixCalculatorCreateInput,
MixCalculatorOptions,
MixCalculatorPreview,
MixCalculatorSession
} from '$lib/types';
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
const todayIso = new Date().toISOString().slice(0, 10);
function initialClientNameValue() {
return initialSession?.client_name ?? options.clients[0] ?? '';
}
function initialProductIdValue() {
return initialSession?.product_id ?? 0;
}
function initialMixDateValue() {
return initialSession?.mix_date ?? todayIso;
}
function initialBatchSizeValue() {
return initialSession ? `${initialSession.batch_size_kg}` : '';
}
function initialPreparedByNameValue() {
return initialSession?.prepared_by_name ?? '';
}
function initialNotesValue() {
return initialSession?.notes ?? '';
}
function initialPreviewValue() {
return initialSession;
}
let clientName = $state(initialClientNameValue());
let productId = $state(initialProductIdValue());
let mixDate = $state(initialMixDateValue());
let batchSizeKg = $state(initialBatchSizeValue());
let preparedByName = $state(initialPreparedByNameValue());
let notes = $state(initialNotesValue());
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
let formError = $state('');
let formSuccess = $state('');
let previewLoading = $state(false);
let saveLoading = $state(false);
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
const isExistingSession = $derived(initialSession !== null);
const availableClients = $derived(
Array.from(new Set([...(options.clients ?? []), ...(initialSession ? [initialSession.client_name] : [])]))
);
const availableProducts = $derived(
initialSession && !options.products.some((product) => product.product_id === initialSession.product_id)
? [
...options.products,
{
product_id: initialSession.product_id,
client_name: initialSession.client_name,
product_name: initialSession.product_name,
mix_id: initialSession.mix_id,
mix_name: initialSession.mix_name,
unit_of_measure: initialSession.product_unit_of_measure,
unit_size_kg: initialSession.product_unit_size_kg,
mix_total_kg: initialSession.total_kg
}
]
: options.products
);
const filteredProducts = $derived(availableProducts.filter((product) => product.client_name === clientName));
const selectedProduct = $derived(filteredProducts.find((product) => product.product_id === productId) ?? null);
$effect(() => {
if (!clientName && availableClients.length) {
clientName = availableClients[0];
}
});
$effect(() => {
if (filteredProducts.length && !filteredProducts.some((product) => product.product_id === productId)) {
productId = filteredProducts[0].product_id;
return;
}
if (!filteredProducts.length) {
productId = 0;
}
});
$effect(() => {
if (!preparedByName && $clientSession?.name) {
preparedByName = $clientSession.name;
}
});
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium'
}).format(new Date(value));
}
function formatNumber(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return value.toFixed(digits);
}
function buildPayload(): MixCalculatorCreateInput | null {
formError = '';
formSuccess = '';
const numericBatchSize = Number(batchSizeKg);
if (!mixDate) {
formError = 'Select a mix date.';
return null;
}
if (!clientName) {
formError = 'Select a client.';
return null;
}
if (!productId) {
formError = 'Select a product.';
return null;
}
if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) {
formError = 'Enter a batch size greater than zero.';
return null;
}
if (!preparedByName.trim()) {
formError = 'Enter the prepared by name.';
return null;
}
return {
mix_date: mixDate,
client_name: clientName,
product_id: productId,
batch_size_kg: numericBatchSize,
prepared_by_name: preparedByName.trim(),
status: initialSession?.status ?? 'saved',
notes: notes.trim() || null
};
}
async function calculatePreview() {
const payload = buildPayload();
if (!payload) {
return;
}
previewLoading = true;
try {
preview = await api.previewMixCalculatorSession(payload);
formSuccess = 'Calculation refreshed from the saved product mix.';
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to calculate the mix session.';
} finally {
previewLoading = false;
}
}
async function saveSession(mode: 'update' | 'create') {
const payload = buildPayload();
if (!payload) {
return;
}
saveLoading = true;
try {
const saved =
mode === 'update' && initialSession
? await api.updateMixCalculatorSession(initialSession.id, payload)
: await api.createMixCalculatorSession(payload);
await goto(`/mix-calculator/${saved.id}`);
} catch (error) {
formError = error instanceof Error ? error.message : 'Unable to save the mix calculator session.';
saveLoading = false;
}
}
</script>
{#if !canEdit && !initialSession}
<section class="locked-card">
<p class="eyebrow">Mix Calculator</p>
<h2>Edit access is required to create a new session.</h2>
<p>View-only users can open saved sessions from history, but cannot create or update production calculations.</p>
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
</section>
{:else}
<section class="page-intro">
<div>
<p class="eyebrow">Mix Calculator</p>
<h2>{isExistingSession ? 'Edit saved mix session' : 'New mix calculation session'}</h2>
<p>Scale a saved product mix by batch size, review required raw materials, then save the session for history and printing.</p>
</div>
<div class="header-actions">
<a class="secondary-button" href="/mix-calculator">Session history</a>
{#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
{/if}
</div>
</section>
<section class="workspace-grid">
<article class="form-card">
<div class="section-header">
<div>
<h3>Session Inputs</h3>
<p>Batch size drives the scale factor. Total bags are derived from the selected product unit size.</p>
</div>
{#if selectedProduct}
<div class="product-pill">
<strong>{selectedProduct.unit_size_kg}kg</strong>
<span>{selectedProduct.unit_of_measure}</span>
</div>
{/if}
</div>
{#if formError}
<p class="message error">{formError}</p>
{/if}
{#if formSuccess}
<p class="message success">{formSuccess}</p>
{/if}
<div class="field-grid">
<label>
<span>Mix date</span>
<input bind:value={mixDate} disabled={!canEdit} type="date" />
</label>
<label>
<span>Client</span>
<select bind:value={clientName} disabled={!canEdit}>
<option value="">Select a client</option>
{#each availableClients as client}
<option value={client}>{client}</option>
{/each}
</select>
</label>
<label class="full-width">
<span>Product</span>
<select bind:value={productId} disabled={!canEdit || !filteredProducts.length}>
<option value={0}>Select a product</option>
{#each filteredProducts as product}
<option value={product.product_id}>
{product.product_name} · {product.mix_name} · {product.unit_of_measure}
</option>
{/each}
</select>
</label>
<label>
<span>Batch size (kg)</span>
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="560" type="number" />
</label>
<label>
<span>Prepared by</span>
<input bind:value={preparedByName} disabled={!canEdit} placeholder="Staff name" type="text" />
</label>
<label class="full-width">
<span>Notes</span>
<textarea bind:value={notes} disabled={!canEdit} placeholder="Optional production notes or shift context" rows="4"></textarea>
</label>
</div>
{#if canEdit && selectedProduct}
<div class="calculation-note">
<strong>Source mix</strong>
<span>{selectedProduct.mix_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source mix total.</span>
</div>
{/if}
{#if canEdit}
<div class="action-row">
<button class="primary-button" disabled={previewLoading || saveLoading} type="button" onclick={calculatePreview}>
{previewLoading ? 'Calculating...' : 'Calculate mix'}
</button>
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create')}>
{saveLoading ? 'Saving...' : isExistingSession ? 'Save changes' : 'Save session'}
</button>
{#if initialSession}
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession('create')}>
Save as new
</button>
{/if}
</div>
{/if}
</article>
<article class="result-card">
<div class="section-header">
<div>
<h3>Calculated Output</h3>
<p>{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}</p>
</div>
{#if initialSession}
<div class="session-chip">
<span>Session</span>
<strong>{initialSession.session_number}</strong>
</div>
{/if}
</div>
{#if preview}
<div class="metric-row">
<article class="metric-card">
<span>Total kg</span>
<strong>{formatNumber(preview.total_kg, 2)}</strong>
<p>Scaled batch size</p>
</article>
<article class="metric-card">
<span>Total bags</span>
<strong>{formatNumber(preview.total_bags, 2)}</strong>
<p>{preview.product_unit_of_measure}</p>
</article>
<article class="metric-card">
<span>Prepared by</span>
<strong>{preview.prepared_by_name}</strong>
<p>{formatDate(preview.mix_date)}</p>
</article>
</div>
{#if preview.warnings.length}
<div class="warning-stack">
{#each preview.warnings as warning}
<p>{warning}</p>
{/each}
</div>
{/if}
<div class="summary-grid">
<div>
<span>Client</span>
<strong>{preview.client_name}</strong>
</div>
<div>
<span>Product</span>
<strong>{preview.product_name}</strong>
</div>
<div>
<span>Mix source</span>
<strong>{preview.mix_name}</strong>
</div>
<div>
<span>Unit size</span>
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each preview.lines as line}
<tr>
<td data-label="Raw material">
<strong>{line.raw_material_name}</strong>
</td>
<td data-label="Mix %">{formatNumber(line.mix_percentage, 2)}%</td>
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
<td data-label="Unit">{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="empty-state">
<strong>No calculation yet</strong>
<span>Choose a client, product, date, and batch size, then run the calculator.</span>
</div>
{/if}
</article>
</section>
{/if}
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.workspace-grid {
margin-bottom: 1.2rem;
}
.page-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.page-intro h2 {
margin: 0.3rem 0 0.45rem;
max-width: 16ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.section-header p,
.metric-card p,
.summary-grid span,
.calculation-note span,
.empty-state span {
color: var(--muted);
}
.header-actions,
.action-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.workspace-grid {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
gap: 1rem;
}
.form-card,
.result-card,
.metric-card,
.locked-card {
border: 1px solid var(--line);
border-radius: 1.3rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.form-card,
.result-card,
.locked-card {
padding: 1.2rem;
}
.locked-card {
max-width: 42rem;
}
.locked-card h2 {
margin: 0.35rem 0 0.45rem;
font-size: clamp(1.7rem, 3vw, 2.1rem);
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.section-header h3 {
font-size: 1.15rem;
font-weight: 700;
}
.product-pill,
.session-chip {
display: grid;
gap: 0.14rem;
padding: 0.72rem 0.82rem;
border: 1px solid var(--line);
border-radius: 0.92rem;
background: var(--panel-soft);
}
.product-pill span,
.session-chip span {
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.field-grid label {
display: grid;
gap: 0.38rem;
}
.field-grid label span {
font-size: 0.88rem;
font-weight: 600;
}
.full-width {
grid-column: 1 / -1;
}
input,
select,
textarea {
width: 100%;
padding: 0.78rem 0.82rem;
border: 1px solid var(--line-strong);
border-radius: 0.88rem;
background: #fff;
color: var(--text);
}
textarea {
resize: vertical;
}
.calculation-note,
.warning-stack,
.empty-state {
margin-top: 1rem;
padding: 0.92rem;
border-radius: 1rem;
}
.calculation-note {
display: grid;
gap: 0.2rem;
background: var(--panel-soft);
}
.warning-stack {
display: grid;
gap: 0.45rem;
background: #fff6e6;
color: #8b5b1e;
}
.message {
margin-bottom: 0.85rem;
padding: 0.75rem 0.85rem;
border-radius: 0.88rem;
font-size: 0.9rem;
}
.message.error {
background: #fff1f0;
color: #b2463f;
}
.message.success {
background: var(--green-soft);
color: var(--green-deep);
}
.action-row {
margin-top: 1rem;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.78rem 0.96rem;
border-radius: 0.9rem;
border: 1px solid var(--line-strong);
font-weight: 600;
cursor: pointer;
}
.primary-button {
border: none;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #fff;
}
.secondary-button {
background: #fff;
color: #304038;
}
button:disabled {
cursor: wait;
opacity: 0.7;
}
.metric-row,
.summary-grid {
display: grid;
gap: 0.85rem;
}
.metric-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 1rem;
}
.metric-card {
padding: 1rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.84rem;
}
.metric-card strong {
display: block;
margin: 0.45rem 0 0.18rem;
font-size: 1.45rem;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 1rem;
}
.summary-grid div {
padding: 0.88rem 0.92rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
.summary-grid span {
display: block;
margin-bottom: 0.2rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
min-width: 30rem;
border-collapse: collapse;
}
th,
td {
padding: 0.9rem 0.85rem;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.empty-state {
display: grid;
gap: 0.2rem;
place-items: start;
background: var(--panel-soft);
}
@media (max-width: 980px) {
.workspace-grid {
grid-template-columns: 1fr;
}
.metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.page-intro,
.section-header {
flex-direction: column;
}
.field-grid,
.summary-grid {
grid-template-columns: 1fr;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.75rem;
}
tbody tr {
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
overflow: hidden;
}
tbody td {
border-bottom: 1px solid var(--line);
}
tbody td:last-child {
border-bottom: none;
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.24rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
}
</style>
+101 -9
View File
@@ -1,6 +1,8 @@
import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
MixCalculatorOptions,
MixCalculatorSession,
Mix,
Product,
ProductCostBreakdown,
@@ -13,6 +15,7 @@ const MODULE_PERMISSIONS = {
dashboard: 'edit',
raw_materials: 'edit',
mix_master: 'edit',
mix_calculator: 'manage',
products: 'edit',
scenarios: 'edit',
powerbi_export: 'edit',
@@ -22,6 +25,7 @@ const MODULE_PERMISSIONS = {
dashboard: 'edit',
raw_materials: 'edit',
mix_master: 'edit',
mix_calculator: 'edit',
products: 'edit',
scenarios: 'edit',
powerbi_export: 'none',
@@ -31,6 +35,7 @@ const MODULE_PERMISSIONS = {
dashboard: 'view',
raw_materials: 'none',
mix_master: 'none',
mix_calculator: 'view',
products: 'view',
scenarios: 'none',
powerbi_export: 'view',
@@ -42,6 +47,7 @@ const MODULE_DETAILS = [
['dashboard', 'Dashboard', 'workspace', 'Top-level operational dashboard'],
['raw_materials', 'Raw Materials', 'costing', 'Maintain live material costs and versions'],
['mix_master', 'Mix Master', 'costing', 'Create and maintain mix worksheets'],
['mix_calculator', 'Mix Calculator', 'production', 'Create and review client-specific mix calculation sessions'],
['products', 'Products', 'pricing', 'Review finished product pricing'],
['scenarios', 'Scenarios', 'planning', 'Run scenario overrides and comparisons'],
['powerbi_export', 'Power BI Export', 'reporting', 'Expose client access data to BI consumers'],
@@ -122,6 +128,70 @@ export const mockProducts: Product[] = [
}
];
export const mockMixCalculatorOptions: MixCalculatorOptions = {
clients: ['Hunter Premium Produce'],
products: [
{
product_id: 1,
client_name: 'Hunter Premium Produce',
product_name: 'Hunter Orchard Blend 20kg',
mix_id: 1,
mix_name: 'Hunter Orchard Blend',
unit_of_measure: '20kg bag',
unit_size_kg: 20,
mix_total_kg: 280
}
]
};
export const mockMixCalculatorSessions: MixCalculatorSession[] = [
{
id: 1,
tenant_id: 'hunter-premium-produce',
session_number: 'HPP-20260429-0001',
client_name: 'Hunter Premium Produce',
product_id: 1,
product_name: 'Hunter Orchard Blend 20kg',
mix_id: 1,
mix_name: 'Hunter Orchard Blend',
mix_date: '2026-04-29',
batch_size_kg: 560,
total_bags: 28,
total_kg: 560,
product_unit_of_measure: '20kg bag',
product_unit_size_kg: 20,
prepared_by_user_id: 1,
prepared_by_name: 'Amelia Hart',
created_by: 'operator@example.com',
status: 'saved',
notes: 'Morning production run',
created_at: '2026-04-29T08:10:00',
updated_at: '2026-04-29T08:12:00',
warnings: [],
is_owner: true,
lines: [
{
id: 1,
raw_material_id: 1,
raw_material_name: 'Maize',
required_kg: 360,
mix_percentage: 64.2857,
unit: 'tonne',
sort_order: 1
},
{
id: 2,
raw_material_id: 2,
raw_material_name: 'Barley',
required_kg: 200,
mix_percentage: 35.7143,
unit: 'tonne',
sort_order: 2
}
]
}
];
export const mockCosts: ProductCostBreakdown[] = [
{
product_id: 1,
@@ -157,8 +227,8 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00',
active_user_count: 1,
new_user_count: 1,
enabled_feature_count: 7,
total_feature_count: 7,
enabled_feature_count: 8,
total_feature_count: 8,
users: [
{
id: 1,
@@ -244,6 +314,17 @@ export const mockClientAccess: ClientAccessAccount[] = [
{
id: 4,
client_account_id: 1,
feature_key: 'mix_calculator',
feature_name: 'Mix Calculator',
feature_group: 'production',
description: 'Create and review client-specific mix calculation sessions',
enabled: true,
updated_at: '2026-04-24T15:00:00',
created_at: '2026-04-20T09:00:00'
},
{
id: 5,
client_account_id: 1,
feature_key: 'products',
feature_name: 'Products',
feature_group: 'pricing',
@@ -253,7 +334,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00'
},
{
id: 5,
id: 6,
client_account_id: 1,
feature_key: 'scenarios',
feature_name: 'Scenarios',
@@ -264,7 +345,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00'
},
{
id: 6,
id: 7,
client_account_id: 1,
feature_key: 'powerbi_export',
feature_name: 'Power BI Export',
@@ -275,7 +356,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00'
},
{
id: 13,
id: 8,
client_account_id: 1,
feature_key: 'client_access',
feature_name: 'Client Access',
@@ -314,8 +395,8 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-21T10:00:00',
active_user_count: 1,
new_user_count: 0,
enabled_feature_count: 3,
total_feature_count: 7,
enabled_feature_count: 4,
total_feature_count: 8,
users: [
{
id: 3,
@@ -378,6 +459,17 @@ export const mockClientAccess: ClientAccessAccount[] = [
{
id: 10,
client_account_id: 2,
feature_key: 'mix_calculator',
feature_name: 'Mix Calculator',
feature_group: 'production',
description: 'Create and review client-specific mix calculation sessions',
enabled: true,
updated_at: '2026-04-22T09:10:00',
created_at: '2026-04-21T10:00:00'
},
{
id: 11,
client_account_id: 2,
feature_key: 'products',
feature_name: 'Products',
feature_group: 'pricing',
@@ -387,7 +479,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-21T10:00:00'
},
{
id: 11,
id: 12,
client_account_id: 2,
feature_key: 'scenarios',
feature_name: 'Scenarios',
@@ -398,7 +490,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-21T10:00:00'
},
{
id: 12,
id: 13,
client_account_id: 2,
feature_key: 'powerbi_export',
feature_name: 'Power BI Export',
+95
View File
@@ -76,6 +76,101 @@ export type MixIngredientUpdateInput = {
notes?: string | null;
};
export type MixCalculatorProductOption = {
product_id: number;
client_name: string;
product_name: string;
mix_id: number;
mix_name: string;
unit_of_measure: string;
unit_size_kg: number;
mix_total_kg: number;
};
export type MixCalculatorOptions = {
clients: string[];
products: MixCalculatorProductOption[];
};
export type MixCalculatorLine = {
id?: number | null;
raw_material_id?: number | null;
raw_material_name: string;
required_kg: number;
mix_percentage: number;
unit: string;
sort_order: number;
};
export type MixCalculatorPreview = {
client_name: string;
product_id: number;
product_name: string;
mix_id: number;
mix_name: string;
mix_date: string;
batch_size_kg: number;
total_bags: number;
total_kg: number;
product_unit_of_measure: string;
product_unit_size_kg: number;
prepared_by_name: string;
status: string;
notes?: string | null;
warnings: string[];
lines: MixCalculatorLine[];
};
export type MixCalculatorSessionSummary = {
id: number;
tenant_id: string;
session_number: string;
client_name: string;
product_id: number;
product_name: string;
mix_id: number;
mix_name: string;
mix_date: string;
batch_size_kg: number;
total_bags: number;
total_kg: number;
product_unit_of_measure: string;
product_unit_size_kg: number;
prepared_by_user_id?: number | null;
prepared_by_name: string;
created_by: string;
status: string;
notes?: string | null;
created_at: string;
updated_at: string;
warnings: string[];
is_owner: boolean;
};
export type MixCalculatorSession = MixCalculatorSessionSummary & {
lines: MixCalculatorLine[];
};
export type MixCalculatorCreateInput = {
mix_date: string;
client_name: string;
product_id: number;
batch_size_kg: number;
prepared_by_name: string;
status?: string;
notes?: string | null;
};
export type MixCalculatorUpdateInput = {
mix_date?: string;
client_name?: string;
product_id?: number;
batch_size_kg?: number;
prepared_by_name?: string;
status?: string;
notes?: string | null;
};
export type Product = {
id: number;
tenant_id?: string;
+4 -1
View File
@@ -6,9 +6,12 @@
let { children } = $props();
const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/'));
const isPrintableRoute = $derived(page.url.pathname.startsWith('/mix-calculator/') && page.url.pathname.endsWith('/print'));
</script>
{#if isAdminRoute}
{#if isPrintableRoute}
{@render children()}
{:else if isAdminRoute}
<AdminShell>
{@render children()}
</AdminShell>
+35
View File
@@ -4,6 +4,9 @@ const apiMocks = vi.hoisted(() => ({
rawMaterials: vi.fn(),
mixes: vi.fn(),
mix: vi.fn(),
mixCalculatorOptions: vi.fn(),
mixCalculatorSessions: vi.fn(),
mixCalculatorSession: vi.fn(),
products: vi.fn(),
productCosts: vi.fn(),
scenarios: vi.fn(),
@@ -30,6 +33,10 @@ import { load as adminLoad } from './admin/+page';
import { load as mixesLoad } from './mixes/+page';
import { load as mixNewLoad } from './mixes/new/+page';
import { load as mixDetailLoad } from './mixes/[id]/+page';
import { load as mixCalculatorLoad } from './mix-calculator/+page';
import { load as mixCalculatorNewLoad } from './mix-calculator/new/+page';
import { load as mixCalculatorDetailLoad } from './mix-calculator/[id]/+page';
import { load as mixCalculatorPrintLoad } from './mix-calculator/[id]/print/+page';
import { load as productsLoad } from './products/+page';
import { load as rawMaterialsLoad } from './raw-materials/+page';
import { load as scenariosLoad } from './scenarios/+page';
@@ -47,6 +54,9 @@ describe('route loaders use the SvelteKit fetch argument', () => {
apiMocks.rawMaterials.mockResolvedValue([{ id: 1 }]);
apiMocks.mixes.mockResolvedValue([{ id: 2 }]);
apiMocks.mix.mockResolvedValue({ id: 42 });
apiMocks.mixCalculatorOptions.mockResolvedValue({ clients: ['Hunter Premium Produce'], products: [{ product_id: 1 }] });
apiMocks.mixCalculatorSessions.mockResolvedValue([{ id: 11 }]);
apiMocks.mixCalculatorSession.mockResolvedValue({ id: 12 });
apiMocks.products.mockResolvedValue([{ id: 3 }]);
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
@@ -108,6 +118,31 @@ describe('route loaders use the SvelteKit fetch argument', () => {
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mix calculator history loader', async () => {
await mixCalculatorLoad({ fetch: fetcher } as never);
expect(apiMocks.mixCalculatorSessions).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the new mix calculator loader', async () => {
await mixCalculatorNewLoad({ fetch: fetcher } as never);
expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mix calculator detail loader', async () => {
await mixCalculatorDetailLoad({ params: { id: '12' }, fetch: fetcher } as never);
expect(apiMocks.mixCalculatorSession).toHaveBeenCalledWith(12, fetcher);
expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher);
});
it('passes fetch through the mix calculator print loader', async () => {
await mixCalculatorPrintLoad({ params: { id: '12' }, fetch: fetcher } as never);
expect(apiMocks.mixCalculatorSession).toHaveBeenCalledWith(12, fetcher);
});
it('passes fetch through the scenarios loader', async () => {
await scenariosLoad({ fetch: fetcher } as never);
@@ -0,0 +1,351 @@
<script lang="ts">
import { clientSession, hasModuleAccess } from '$lib/session';
import type { MixCalculatorSession } from '$lib/types';
let { data }: { data: { sessions: MixCalculatorSession[] } } = $props();
const canEdit = $derived(hasModuleAccess($clientSession, 'mix_calculator', 'edit'));
function formatDate(value: string) {
return new Intl.DateTimeFormat('en-NZ', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function formatNumber(value: number, digits = 2) {
return value.toFixed(digits);
}
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Mix Calculator</p>
<h2>Saved production sessions</h2>
<p>Each session preserves the scaled raw material output so it can be reopened or printed later without relying on live recipe changes.</p>
</div>
{#if canEdit}
<a class="primary-button" href="/mix-calculator/new">New mix session</a>
{/if}
</section>
<section class="metric-row">
<article class="metric-card">
<span>Saved Sessions</span>
<strong>{data.sessions.length}</strong>
<p>Visible under your access scope</p>
</article>
<article class="metric-card">
<span>Total Planned Kg</span>
<strong>{formatNumber(data.sessions.reduce((sum, session) => sum + session.total_kg, 0), 2)}</strong>
<p>Across the visible history</p>
</article>
<article class="metric-card">
<span>Sessions With Warnings</span>
<strong>{data.sessions.filter((session) => session.warnings.length).length}</strong>
<p>Fractional bag outputs need review</p>
</article>
</section>
<section class="table-card">
<div class="table-toolbar">
<div>
<h3>Session history</h3>
<p>Operators see their own sessions. Superadmins and admins see the full client history.</p>
</div>
</div>
{#if data.sessions.length}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Session</th>
<th>Client / Product</th>
<th>Batch</th>
<th>Bags</th>
<th>Prepared by</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{#each data.sessions as session}
<tr>
<td data-label="Session">
<strong>{session.session_number}</strong>
<span>{session.mix_name}</span>
</td>
<td data-label="Client / Product">
<strong>{session.product_name}</strong>
<span>{session.client_name}</span>
</td>
<td data-label="Batch">{formatNumber(session.batch_size_kg, 2)}kg</td>
<td data-label="Bags">
{formatNumber(session.total_bags, 2)}
{#if session.warnings.length}
<span class="warning-pill">Warn</span>
{/if}
</td>
<td data-label="Prepared by">{session.prepared_by_name}</td>
<td data-label="Updated">{formatDate(session.updated_at)}</td>
<td data-label="Open">
<div class="row-actions">
<a href={`/mix-calculator/${session.id}`}>Open</a>
<a href={`/mix-calculator/${session.id}/print`}>Print</a>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="empty-state">
<strong>No saved sessions yet</strong>
<span>{canEdit ? 'Run a new calculation and save it to start your session history.' : 'No sessions are visible under your access scope yet.'}</span>
</div>
{/if}
</section>
<style>
h2,
h3,
p {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-intro,
.metric-row,
.table-card {
margin-bottom: 1.25rem;
}
.page-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 15ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p,
.table-toolbar p,
tbody span {
color: var(--muted);
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.78rem 0.96rem;
border-radius: 0.9rem;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #fff;
font-weight: 600;
}
.metric-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.metric-card,
.table-card {
border: 1px solid var(--line);
border-radius: 1.3rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.metric-card {
padding: 1.15rem 1.2rem;
}
.metric-card span {
display: block;
color: var(--muted);
font-size: 0.9rem;
}
.metric-card strong {
display: block;
margin: 0.55rem 0 0.3rem;
font-size: 1.9rem;
}
.table-card {
padding: 1.2rem;
}
.table-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
min-width: 54rem;
border-collapse: separate;
border-spacing: 0 0.75rem;
}
th,
td {
padding: 1rem;
text-align: left;
white-space: nowrap;
}
th {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
tbody td {
background: var(--panel-soft);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
tbody td:first-child {
border-left: 1px solid var(--line);
border-radius: 1rem 0 0 1rem;
}
tbody td:last-child {
border-right: 1px solid var(--line);
border-radius: 0 1rem 1rem 0;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.8rem;
}
.row-actions a {
font-weight: 600;
}
.warning-pill {
display: inline-flex;
margin-left: 0.55rem;
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: #fff6e6;
color: #8b5b1e;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.empty-state {
display: grid;
gap: 0.2rem;
padding: 1rem;
border-radius: 1rem;
background: var(--panel-soft);
}
@media (max-width: 900px) {
.metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.page-intro,
.table-toolbar {
flex-direction: column;
align-items: flex-start;
}
table,
thead,
tbody,
tr,
td {
display: block;
width: 100%;
}
table {
min-width: 0;
border-spacing: 0;
}
thead {
display: none;
}
tbody {
display: grid;
gap: 0.9rem;
}
tbody tr {
padding: 0.3rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-soft);
}
tbody td {
padding: 0.78rem 0.8rem;
white-space: normal;
border: none;
border-radius: 0;
background: transparent;
}
tbody td:first-child,
tbody td:last-child {
border: none;
border-radius: 0;
}
tbody td + td {
border-top: 1px solid var(--line);
}
tbody td::before {
content: attr(data-label);
display: block;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
}
</style>
@@ -0,0 +1,22 @@
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
sessions: []
};
}
const session = getStoredClientSession();
try {
return {
sessions: hasModuleAccess(session, 'mix_calculator') ? await api.mixCalculatorSessions(fetch) : []
};
} catch {
return {
sessions: []
};
}
}
@@ -0,0 +1,61 @@
<script lang="ts">
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
let { data } = $props();
</script>
{#if data.session}
<MixCalculatorWorkspace initialSession={data.session} options={data.options} />
{:else}
<section class="locked-card">
<p class="eyebrow">Mix Calculator</p>
<h2>Session unavailable.</h2>
<p>The requested mix calculator session could not be loaded with the current access scope.</p>
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
</section>
{/if}
<style>
h2,
p {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.locked-card {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.locked-card h2 {
margin-top: 0.35rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
}
.locked-card p:last-of-type {
margin-top: 0.45rem;
color: var(--muted);
}
.secondary-button {
display: inline-flex;
margin-top: 1rem;
padding: 0.78rem 0.92rem;
border: 1px solid var(--line-strong);
border-radius: 0.88rem;
background: #fff;
color: #304038;
font-weight: 600;
}
</style>
@@ -0,0 +1,39 @@
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ params, fetch }) {
if (!hasStoredClientSession()) {
return {
session: null,
options: { clients: [], products: [] }
};
}
const session = getStoredClientSession();
const canView = hasModuleAccess(session, 'mix_calculator');
const canEdit = hasModuleAccess(session, 'mix_calculator', 'edit');
if (!canView) {
return {
session: null,
options: { clients: [], products: [] }
};
}
try {
const [savedSession, options] = await Promise.all([
api.mixCalculatorSession(Number(params.id), fetch),
canEdit ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] })
]);
return {
session: savedSession,
options
};
} catch {
return {
session: null,
options: { clients: [], products: [] }
};
}
}
@@ -0,0 +1,61 @@
<script lang="ts">
import MixCalculatorPrintSheet from '$lib/components/MixCalculatorPrintSheet.svelte';
let { data } = $props();
</script>
{#if data.session}
<MixCalculatorPrintSheet session={data.session} />
{:else}
<section class="locked-card">
<p class="eyebrow">Mix Calculator</p>
<h2>Printable session unavailable.</h2>
<p>The saved session could not be loaded for printing.</p>
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
</section>
{/if}
<style>
h2,
p {
margin: 0;
}
.eyebrow {
color: #7d8d84;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.locked-card {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.locked-card h2 {
margin-top: 0.35rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
}
.locked-card p:last-of-type {
margin-top: 0.45rem;
color: var(--muted);
}
.secondary-button {
display: inline-flex;
margin-top: 1rem;
padding: 0.78rem 0.92rem;
border: 1px solid var(--line-strong);
border-radius: 0.88rem;
background: #fff;
color: #304038;
font-weight: 600;
}
</style>
@@ -0,0 +1,28 @@
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ params, fetch }) {
if (!hasStoredClientSession()) {
return {
session: null
};
}
const session = getStoredClientSession();
if (!hasModuleAccess(session, 'mix_calculator')) {
return {
session: null
};
}
try {
return {
session: await api.mixCalculatorSession(Number(params.id), fetch)
};
} catch {
return {
session: null
};
}
}
@@ -0,0 +1,6 @@
<script lang="ts">
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
let { data } = $props();
</script>
<MixCalculatorWorkspace options={data.options} />
@@ -0,0 +1,24 @@
import { api } from '$lib/api';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
options: { clients: [], products: [] }
};
}
const session = getStoredClientSession();
try {
return {
options: hasModuleAccess(session, 'mix_calculator', 'edit')
? await api.mixCalculatorOptions(fetch)
: { clients: [], products: [] }
};
} catch {
return {
options: { clients: [], products: [] }
};
}
}