Mix calculator
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: [] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user