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, "raw_material_price_versions": None,
"mixes": None, "mixes": None,
"mix_ingredients": None, "mix_ingredients": None,
"mix_calculator_sessions": None,
"mix_calculator_session_lines": None,
"products": None, "products": None,
"scenarios": None, "scenarios": None,
"costing_results": 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", "scenarios",
text( text(
+3
View File
@@ -14,6 +14,7 @@ import uvicorn
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.client_access import router as client_access_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.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router from app.api.powerbi import router as powerbi_router
from app.api.products import router as products_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(client_access_router)
app.include_router(raw_materials_router) app.include_router(raw_materials_router)
app.include_router(mixes_router) app.include_router(mixes_router)
app.include_router(mix_calculator_router)
app.include_router(products_router) app.include_router(products_router)
app.include_router(scenarios_router) app.include_router(scenarios_router)
app.include_router(powerbi_router) app.include_router(powerbi_router)
@@ -95,6 +97,7 @@ def root():
"admin_login": "/api/auth/admin/login", "admin_login": "/api/auth/admin/login",
"raw_materials": "/api/raw-materials", "raw_materials": "/api/raw-materials",
"mixes": "/api/mixes", "mixes": "/api/mixes",
"mix_calculator": "/api/mix-calculator",
"products": "/api/products", "products": "/api/products",
"scenarios": "/api/scenarios", "scenarios": "/api/scenarios",
"client_access": "/api/client-access", "client_access": "/api/client-access",
+3
View File
@@ -1,5 +1,6 @@
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission 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.mix import Mix, MixIngredient
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
@@ -14,6 +15,8 @@ __all__ = [
"CostingResult", "CostingResult",
"FreightCostRule", "FreightCostRule",
"Mix", "Mix",
"MixCalculatorSession",
"MixCalculatorSessionLine",
"MixIngredient", "MixIngredient",
"PackagingCostRule", "PackagingCostRule",
"ProcessCostRule", "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 = { enabled_feature_map = {
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export", "client_access"}, "hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"},
"loft-grains": {"dashboard", "products", "powerbi_export"}, "loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"},
} }
for client in (specialty, loft): for client in (specialty, loft):
+57 -4
View File
@@ -17,6 +17,7 @@ MODULE_CATALOG = (
("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"), ("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"),
("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"), ("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"),
("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"), ("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"), ("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"), ("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"), ("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]: def list_client_accounts(db: Session) -> list[ClientAccount]:
ensure_client_user_module_permissions(db) ensure_client_user_module_permissions(db)
ensure_client_feature_access(db)
return db.scalars(client_access_query()).all() return db.scalars(client_access_query()).all()
@@ -50,9 +52,19 @@ def get_client_user_by_email(db: Session, *, email: str, tenant_id: str | None =
statement = select(ClientUser).where(ClientUser.email == email) statement = select(ClientUser).where(ClientUser.email == email)
if tenant_id: if tenant_id:
statement = statement.where(ClientUser.tenant_id == tenant_id) statement = statement.where(ClientUser.tenant_id == tenant_id)
return db.scalar( user = db.scalar(
statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc()) 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]: def module_access_map(user: ClientUser) -> dict[str, str]:
@@ -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: def default_access_level_for_role(role: str, module_key: str) -> str:
normalized = role.strip().lower() normalized = role.strip().lower()
if normalized == "superadmin": 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 normalized == "admin":
if module_key == "mix_calculator":
return "manage"
return "edit" if module_key != "client_access" else "none" return "edit" if module_key != "client_access" else "none"
if normalized == "operator": 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": 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" return "none"
@@ -106,6 +120,45 @@ def ensure_client_user_module_permissions(db: Session) -> None:
db.commit() 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: def serialize_client_user(user: ClientUser) -> dict:
return { return {
"id": user.id, "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) 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() normalized = unit_of_measure.strip().lower()
if normalized == "tonne": if normalized == "tonne":
return 1000.0 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) mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
warnings = list(mix_result["warnings"]) 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 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) 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)
+132 -22
View File
@@ -11,11 +11,13 @@ from app.db.session import Base
from app.main import app from app.main import app
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser 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.mix import Mix, MixIngredient
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion 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.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.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: def build_session() -> Session:
@@ -86,30 +88,83 @@ def test_mix_and_product_cost_breakdown():
assert product_result["wholesale_price"] == 17.3268 assert product_result["wholesale_price"] == 17.3268
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(): def test_root_and_login_endpoints():
client = TestClient(app) 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"
assert root_response.json()["endpoints"]["admin_login"] == "/api/auth/admin/login"
root_response = client.get("/") client_login_response = client.post(
assert root_response.status_code == 200 "/api/auth/client/login",
assert root_response.json()["endpoints"]["client_login"] == "/api/auth/client/login" json={"email": settings.client_email, "password": settings.client_password},
assert root_response.json()["endpoints"]["admin_login"] == "/api/auth/admin/login" )
assert client_login_response.status_code == 200
assert client_login_response.json()["email"] == settings.client_email
assert client_login_response.json()["tenant_id"] == settings.client_tenant_id
assert client_login_response.json()["client_role"] == "superadmin"
assert client_login_response.json()["module_permissions"]["client_access"] == "manage"
client_login_response = client.post( admin_login_response = client.post(
"/api/auth/client/login", "/api/auth/admin/login",
json={"email": settings.client_email, "password": settings.client_password}, json={"email": settings.admin_email, "password": settings.admin_password},
) )
assert client_login_response.status_code == 200 assert admin_login_response.status_code == 200
assert client_login_response.json()["email"] == settings.client_email assert admin_login_response.json()["email"] == settings.admin_email
assert client_login_response.json()["tenant_id"] == settings.client_tenant_id
assert client_login_response.json()["client_role"] == "superadmin"
assert client_login_response.json()["module_permissions"]["client_access"] == "manage"
admin_login_response = client.post(
"/api/auth/admin/login",
json={"email": settings.admin_email, "password": settings.admin_password},
)
assert admin_login_response.status_code == 200
assert admin_login_response.json()["email"] == settings.admin_email
def test_client_access_export_helpers(): def test_client_access_export_helpers():
@@ -220,6 +275,61 @@ def test_client_access_endpoints():
assert len(superadmin_access_response.json()) == 1 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(): def test_module_permission_blocks_client_module_access():
with TestClient(app) as client: with TestClient(app) as client:
admin_login_response = client.post( 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']}"} admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"}
access_response = client.get("/api/client-access", headers=admin_headers) access_response = client.get("/api/client-access", headers=admin_headers)
first_client = access_response.json()[0] 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 = next(
permission for permission in first_user["module_permissions"] if permission["module_key"] == "raw_materials" permission for permission in first_user["module_permissions"] if permission["module_key"] == "raw_materials"
+28
View File
@@ -4,6 +4,8 @@ import {
mockClientAccess, mockClientAccess,
mockClientAccessExport, mockClientAccessExport,
mockCosts, mockCosts,
mockMixCalculatorOptions,
mockMixCalculatorSessions,
mockMixes, mockMixes,
mockProducts, mockProducts,
mockRawMaterials, mockRawMaterials,
@@ -16,6 +18,11 @@ import type {
ClientUserModulePermission, ClientUserModulePermission,
ClientUserUpdateInput, ClientUserUpdateInput,
LoginResponse, LoginResponse,
MixCalculatorCreateInput,
MixCalculatorOptions,
MixCalculatorPreview,
MixCalculatorSession,
MixCalculatorUpdateInput,
Mix, Mix,
MixCreateInput, MixCreateInput,
MixIngredientUpdateInput, MixIngredientUpdateInput,
@@ -128,6 +135,27 @@ export const api = {
rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher), rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, '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), 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), products: (fetcher?: ApiFetch) => fetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
productCosts: (fetcher?: ApiFetch) => productCosts: (fetcher?: ApiFetch) =>
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher), fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
+63 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
@@ -23,6 +24,7 @@
}; };
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' }; 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[] = [ const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' }, { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' }, { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' },
@@ -30,7 +32,7 @@
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' } { href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' }
]; ];
const accessControlItem: NavItem = { href: '/client-access', label: 'Client Access', shortLabel: 'AC', moduleKey: 'client_access' }; 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 = [ const footerLinks = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' }, { href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
@@ -62,6 +64,18 @@
description: 'Start a new costing worksheet for Hunter Premium Produce.', description: 'Start a new costing worksheet for Hunter Premium Produce.',
keywords: 'new mix create worksheet hunter premium produce formula' 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', href: '/products',
label: 'Open Products', label: 'Open Products',
@@ -105,6 +119,11 @@
? workingDocumentItems ? workingDocumentItems
: workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey)) : workingDocumentItems.filter((item) => !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey))
); );
const visibleMixCalculatorItem = $derived(
!$clientSession || !mixCalculatorItem.moduleKey || hasModuleAccess($clientSession, mixCalculatorItem.moduleKey)
? mixCalculatorItem
: null
);
const visibleFooterLinks = $derived([ const visibleFooterLinks = $derived([
...footerLinks, ...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage') ...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
@@ -112,7 +131,11 @@
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }]) : [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }])
]); ]);
const primaryBottomNavigation = $derived( const primaryBottomNavigation = $derived(
[...(visibleDashboardItem ? [visibleDashboardItem] : []), ...visibleWorkingDocumentItems.slice(0, 3)] [
...(visibleDashboardItem ? [visibleDashboardItem] : []),
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...visibleWorkingDocumentItems.slice(0, 2)
]
); );
function matchesRoute(href: string, pathname: string) { function matchesRoute(href: string, pathname: string) {
@@ -124,11 +147,17 @@
} }
function pageDescription(pathname: string) { 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> = { const descriptions: Record<string, string> = {
'/': 'Hunter Premium Produce client workspace', '/': 'Hunter Premium Produce client workspace',
'/raw-materials': 'Review source input costs and downstream exposure', '/raw-materials': 'Review source input costs and downstream exposure',
'/mixes': 'Browse saved mix worksheets and costing outputs', '/mixes': 'Browse saved mix worksheets and costing outputs',
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce', '/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', '/products': 'Track delivered product pricing and margin views',
'/settings': 'Review your workspace profile and application settings', '/settings': 'Review your workspace profile and application settings',
'/scenarios': 'Compare alternate pricing and production assumptions', '/scenarios': 'Compare alternate pricing and production assumptions',
@@ -210,11 +239,19 @@
restoredToken = token; restoredToken = token;
isRestoringSession = true; isRestoringSession = true;
invalidateAll().finally(() => { api.clientSession()
if (restoredToken === token) { .then((session) => {
restoredToken = session.token;
clientSession.set(session);
return invalidateAll();
})
.catch(() => {
restoredToken = null;
clientSession.clear();
})
.finally(() => {
isRestoringSession = false; isRestoringSession = false;
} });
});
}); });
$effect(() => { $effect(() => {
@@ -319,6 +356,13 @@
<span>{visibleDashboardItem.label}</span> <span>{visibleDashboardItem.label}</span>
</a> </a>
{/if} {/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> </nav>
<div class="nav-group" aria-label="Working documents" hidden={!visibleWorkingDocumentItems.length}> <div class="nav-group" aria-label="Working documents" hidden={!visibleWorkingDocumentItems.length}>
@@ -396,6 +440,8 @@
<div class="menu-panel"> <div class="menu-panel">
<a href="/mixes">Open mix costing</a> <a href="/mixes">Open mix costing</a>
<a href="/mixes/new">Create mix worksheet</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> <a href="/products">Review delivered pricing</a>
<button type="button" onclick={() => openPalette('')}>Search the workspace</button> <button type="button" onclick={() => openPalette('')}>Search the workspace</button>
</div> </div>
@@ -568,6 +614,13 @@
</a> </a>
{/if} {/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}> <div class="drawer-group" hidden={!visibleWorkingDocumentItems.length}>
<button <button
aria-controls="drawer-working-documents-nav" aria-controls="drawer-working-documents-nav"
@@ -597,6 +650,10 @@
<span class="nav-icon">NW</span> <span class="nav-icon">NW</span>
<span>Create mix worksheet</span> <span>Create mix worksheet</span>
</a> </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}> <button type="button" onclick={openSettings}>
<span class="nav-icon muted">ST</span> <span class="nav-icon muted">ST</span>
<span>Change settings</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 { import type {
ClientAccessAccount, ClientAccessAccount,
ClientAccessPowerBiExport, ClientAccessPowerBiExport,
MixCalculatorOptions,
MixCalculatorSession,
Mix, Mix,
Product, Product,
ProductCostBreakdown, ProductCostBreakdown,
@@ -13,6 +15,7 @@ const MODULE_PERMISSIONS = {
dashboard: 'edit', dashboard: 'edit',
raw_materials: 'edit', raw_materials: 'edit',
mix_master: 'edit', mix_master: 'edit',
mix_calculator: 'manage',
products: 'edit', products: 'edit',
scenarios: 'edit', scenarios: 'edit',
powerbi_export: 'edit', powerbi_export: 'edit',
@@ -22,6 +25,7 @@ const MODULE_PERMISSIONS = {
dashboard: 'edit', dashboard: 'edit',
raw_materials: 'edit', raw_materials: 'edit',
mix_master: 'edit', mix_master: 'edit',
mix_calculator: 'edit',
products: 'edit', products: 'edit',
scenarios: 'edit', scenarios: 'edit',
powerbi_export: 'none', powerbi_export: 'none',
@@ -31,6 +35,7 @@ const MODULE_PERMISSIONS = {
dashboard: 'view', dashboard: 'view',
raw_materials: 'none', raw_materials: 'none',
mix_master: 'none', mix_master: 'none',
mix_calculator: 'view',
products: 'view', products: 'view',
scenarios: 'none', scenarios: 'none',
powerbi_export: 'view', powerbi_export: 'view',
@@ -42,6 +47,7 @@ const MODULE_DETAILS = [
['dashboard', 'Dashboard', 'workspace', 'Top-level operational dashboard'], ['dashboard', 'Dashboard', 'workspace', 'Top-level operational dashboard'],
['raw_materials', 'Raw Materials', 'costing', 'Maintain live material costs and versions'], ['raw_materials', 'Raw Materials', 'costing', 'Maintain live material costs and versions'],
['mix_master', 'Mix Master', 'costing', 'Create and maintain mix worksheets'], ['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'], ['products', 'Products', 'pricing', 'Review finished product pricing'],
['scenarios', 'Scenarios', 'planning', 'Run scenario overrides and comparisons'], ['scenarios', 'Scenarios', 'planning', 'Run scenario overrides and comparisons'],
['powerbi_export', 'Power BI Export', 'reporting', 'Expose client access data to BI consumers'], ['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[] = [ export const mockCosts: ProductCostBreakdown[] = [
{ {
product_id: 1, product_id: 1,
@@ -157,8 +227,8 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00', created_at: '2026-04-20T09:00:00',
active_user_count: 1, active_user_count: 1,
new_user_count: 1, new_user_count: 1,
enabled_feature_count: 7, enabled_feature_count: 8,
total_feature_count: 7, total_feature_count: 8,
users: [ users: [
{ {
id: 1, id: 1,
@@ -244,6 +314,17 @@ export const mockClientAccess: ClientAccessAccount[] = [
{ {
id: 4, id: 4,
client_account_id: 1, 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_key: 'products',
feature_name: 'Products', feature_name: 'Products',
feature_group: 'pricing', feature_group: 'pricing',
@@ -253,7 +334,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00' created_at: '2026-04-20T09:00:00'
}, },
{ {
id: 5, id: 6,
client_account_id: 1, client_account_id: 1,
feature_key: 'scenarios', feature_key: 'scenarios',
feature_name: 'Scenarios', feature_name: 'Scenarios',
@@ -264,7 +345,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00' created_at: '2026-04-20T09:00:00'
}, },
{ {
id: 6, id: 7,
client_account_id: 1, client_account_id: 1,
feature_key: 'powerbi_export', feature_key: 'powerbi_export',
feature_name: 'Power BI Export', feature_name: 'Power BI Export',
@@ -275,7 +356,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-20T09:00:00' created_at: '2026-04-20T09:00:00'
}, },
{ {
id: 13, id: 8,
client_account_id: 1, client_account_id: 1,
feature_key: 'client_access', feature_key: 'client_access',
feature_name: 'Client Access', feature_name: 'Client Access',
@@ -314,8 +395,8 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-21T10:00:00', created_at: '2026-04-21T10:00:00',
active_user_count: 1, active_user_count: 1,
new_user_count: 0, new_user_count: 0,
enabled_feature_count: 3, enabled_feature_count: 4,
total_feature_count: 7, total_feature_count: 8,
users: [ users: [
{ {
id: 3, id: 3,
@@ -378,6 +459,17 @@ export const mockClientAccess: ClientAccessAccount[] = [
{ {
id: 10, id: 10,
client_account_id: 2, 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_key: 'products',
feature_name: 'Products', feature_name: 'Products',
feature_group: 'pricing', feature_group: 'pricing',
@@ -387,7 +479,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-21T10:00:00' created_at: '2026-04-21T10:00:00'
}, },
{ {
id: 11, id: 12,
client_account_id: 2, client_account_id: 2,
feature_key: 'scenarios', feature_key: 'scenarios',
feature_name: 'Scenarios', feature_name: 'Scenarios',
@@ -398,7 +490,7 @@ export const mockClientAccess: ClientAccessAccount[] = [
created_at: '2026-04-21T10:00:00' created_at: '2026-04-21T10:00:00'
}, },
{ {
id: 12, id: 13,
client_account_id: 2, client_account_id: 2,
feature_key: 'powerbi_export', feature_key: 'powerbi_export',
feature_name: 'Power BI Export', feature_name: 'Power BI Export',
+95
View File
@@ -76,6 +76,101 @@ export type MixIngredientUpdateInput = {
notes?: string | null; 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 = { export type Product = {
id: number; id: number;
tenant_id?: string; tenant_id?: string;
+4 -1
View File
@@ -6,9 +6,12 @@
let { children } = $props(); let { children } = $props();
const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/')); 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> </script>
{#if isAdminRoute} {#if isPrintableRoute}
{@render children()}
{:else if isAdminRoute}
<AdminShell> <AdminShell>
{@render children()} {@render children()}
</AdminShell> </AdminShell>
+35
View File
@@ -4,6 +4,9 @@ const apiMocks = vi.hoisted(() => ({
rawMaterials: vi.fn(), rawMaterials: vi.fn(),
mixes: vi.fn(), mixes: vi.fn(),
mix: vi.fn(), mix: vi.fn(),
mixCalculatorOptions: vi.fn(),
mixCalculatorSessions: vi.fn(),
mixCalculatorSession: vi.fn(),
products: vi.fn(), products: vi.fn(),
productCosts: vi.fn(), productCosts: vi.fn(),
scenarios: 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 mixesLoad } from './mixes/+page';
import { load as mixNewLoad } from './mixes/new/+page'; import { load as mixNewLoad } from './mixes/new/+page';
import { load as mixDetailLoad } from './mixes/[id]/+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 productsLoad } from './products/+page';
import { load as rawMaterialsLoad } from './raw-materials/+page'; import { load as rawMaterialsLoad } from './raw-materials/+page';
import { load as scenariosLoad } from './scenarios/+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.rawMaterials.mockResolvedValue([{ id: 1 }]);
apiMocks.mixes.mockResolvedValue([{ id: 2 }]); apiMocks.mixes.mockResolvedValue([{ id: 2 }]);
apiMocks.mix.mockResolvedValue({ id: 42 }); 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.products.mockResolvedValue([{ id: 3 }]);
apiMocks.productCosts.mockResolvedValue([{ id: 4 }]); apiMocks.productCosts.mockResolvedValue([{ id: 4 }]);
apiMocks.scenarios.mockResolvedValue([{ id: 5 }]); apiMocks.scenarios.mockResolvedValue([{ id: 5 }]);
@@ -108,6 +118,31 @@ describe('route loaders use the SvelteKit fetch argument', () => {
expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher); 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 () => { it('passes fetch through the scenarios loader', async () => {
await scenariosLoad({ fetch: fetcher } as never); 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: [] }
};
}
}