Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements

This commit is contained in:
2026-05-08 00:00:56 +12:00
parent ebee72d4df
commit 1533b5aa9b
29 changed files with 1851 additions and 520 deletions
+177
View File
@@ -0,0 +1,177 @@
"""Internal-user authentication and permission introspection routes.
Frontends should call ``GET /api/access/me`` to discover which permission keys
the current user has, then use those keys to hide/show navigation items.
**Visibility is not security** — every privileged backend route must depend on
``require_permission`` (or one of its siblings) directly.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.access import (
INTERNAL_USER_SUBJECT,
INTERNAL_USER_TENANT_ID,
get_current_user,
get_user_permissions,
permissions_to_module_map,
require_permission,
)
from app.core.config import settings
from app.core.security import issue_token
from app.db.session import get_db
from app.models.access import Permission, Role, User
router = APIRouter(prefix="/api/access", tags=["access"])
class LoginRequest(BaseModel):
email: str
password: str
class UserSession(BaseModel):
# Mirrors the existing `LoginResponse` shape so the frontend's `AppSession`
# store can consume this response without a separate type. `permissions`
# is the new permission-key array; `module_permissions` is the legacy
# module→access-level map for nav gating.
user_id: int
email: str
name: str
role: str
role_name: str | None = None
is_active: bool
tenant_id: str | None = None
client_role: str | None = None
client_account_id: int | None = None
module_permissions: dict[str, str] = {}
permissions: list[str]
token: str | None = None
class RoleRead(BaseModel):
id: int
name: str
description: str | None
permissions: list[str]
class UserRead(BaseModel):
id: int
email: str
name: str
is_active: bool
role: str | None
def _serialize_session(user: User, *, include_token: bool = False) -> UserSession:
permission_set = get_user_permissions(user)
permissions = sorted(permission_set)
module_permissions = permissions_to_module_map(permission_set)
role_name = user.role.name if user.role else None
token = None
if include_token:
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
# role="internal" is a marker the shared auth deps recognise so internal
# users can hit the same routes as client-portal users without being
# confused with them. Display name lives in role_name / client_role.
return UserSession(
user_id=user.id,
email=user.email,
name=user.name,
role="internal",
role_name=role_name,
is_active=user.is_active,
tenant_id=INTERNAL_USER_TENANT_ID,
client_role=role_name,
client_account_id=None,
module_permissions=module_permissions,
permissions=permissions,
token=token,
)
@router.post("/login", response_model=UserSession)
def login(payload: LoginRequest, db: Session = Depends(get_db)):
"""Internal-user login.
Authenticates against a shared internal password (``ADMIN_PASSWORD``) and
looks up the user by email. Inactive or unknown users are rejected with
a generic 401 to avoid leaking which emails are valid.
"""
if payload.password != settings.admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
email = payload.email.strip().lower()
user = db.scalar(
select(User)
.where(User.email == email)
.options(selectinload(User.role).selectinload(Role.permissions))
)
if user is None or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
return _serialize_session(user, include_token=True)
@router.get("/me", response_model=UserSession)
def read_me(user: User = Depends(get_current_user)):
"""Return the current user with permission keys for UI navigation gating."""
return _serialize_session(user)
@router.get("/me/permissions", response_model=list[str])
def read_my_permissions(user: User = Depends(get_current_user)):
return sorted(get_user_permissions(user))
# Permission-enforced administrative endpoints. Route bodies should not check
# role names — every gate is the require_permission(...) dependency.
@router.get("/users", response_model=list[UserRead])
def list_users(
db: Session = Depends(get_db),
_: User = Depends(require_permission("view_users")), # gated by permission key
):
users = db.scalars(select(User).options(selectinload(User.role))).all()
return [
UserRead(
id=user.id,
email=user.email,
name=user.name,
is_active=user.is_active,
role=user.role.name if user.role else None,
)
for user in users
]
@router.get("/roles", response_model=list[RoleRead])
def list_roles(
db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_permissions")), # gated by permission key
):
roles = db.scalars(
select(Role).options(selectinload(Role.permissions)).order_by(Role.name)
).all()
return [
RoleRead(
id=role.id,
name=role.name,
description=role.description,
permissions=sorted(p.key for p in role.permissions),
)
for role in roles
]
@router.get("/permissions", response_model=list[str])
def list_permissions(
db: Session = Depends(get_db),
_: User = Depends(require_permission("manage_permissions")), # gated by permission key
):
return sorted(p.key for p in db.scalars(select(Permission)).all())
+150
View File
@@ -0,0 +1,150 @@
"""Dashboard summary endpoint.
Returns only the aggregates the homepage actually renders — counts, top items,
totals, and a trend-chart series. Replaces a Dashboard load that previously
fetched five full collections (raw materials, mixes, all product cost
breakdowns, scenarios, data-quality) and only used summaries from each.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import AuthSession, require_client_session
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.models.raw_material import RawMaterial
from app.services.client_access_service import has_access_level
from app.services.costing_engine import (
calculate_mix_cost,
calculate_product_cost,
get_active_price,
calculate_raw_material_cost,
)
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
def _can(session: AuthSession, module_key: str) -> bool:
permissions = session.module_permissions or {}
return has_access_level(permissions.get(module_key), "view")
@router.get("/summary")
def dashboard_summary(
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
):
raw_materials_summary: dict | None = None
mixes_summary: dict | None = None
products_summary: dict | None = None
raw_series: list[float] = []
mix_series: list[float] = []
product_series: list[float] = []
if _can(session, "raw_materials") or _can(session, "dashboard"):
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
).all()
total_market_value = 0.0
latest = None
latest_date = None
for material in materials:
active = get_active_price(material)
if active is None:
continue
comp = calculate_raw_material_cost(material, active)
raw_series.append(comp.cost_per_kg)
total_market_value += active.market_value
if latest_date is None or active.effective_date > latest_date:
latest_date = active.effective_date
latest = {
"id": material.id,
"name": material.name,
"market_value": active.market_value,
"cost_per_kg": comp.cost_per_kg,
"effective_date": active.effective_date.isoformat() if active.effective_date else None,
}
raw_materials_summary = {
"count": len(materials),
"total_market_value": round(total_market_value, 4),
"latest": latest,
}
if _can(session, "mix_master") or _can(session, "dashboard"):
mix_rows = db.scalars(
select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)
).all()
cost_sum = 0.0
cost_count = 0
top_mix: dict | None = None
for mix in mix_rows:
result = calculate_mix_cost(db, mix.id)
cost_per_kg = result.get("mix_cost_per_kg")
if cost_per_kg is not None:
mix_series.append(cost_per_kg)
cost_sum += cost_per_kg
cost_count += 1
if top_mix is None or (cost_per_kg or 0) > (top_mix.get("mix_cost_per_kg") or 0):
top_mix = {
"id": result["id"],
"name": result["name"],
"client_name": result["client_name"],
"ingredients_count": len(result["ingredients"]),
"total_mix_kg": result["total_mix_kg"],
"total_mix_cost": result["total_mix_cost"],
"mix_cost_per_kg": cost_per_kg,
"warnings": result["warnings"],
}
mixes_summary = {
"count": len(mix_rows),
"average_cost_per_kg": round(cost_sum / cost_count, 4) if cost_count else 0.0,
"top": top_mix,
}
if _can(session, "products") or _can(session, "dashboard"):
products = db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id)
).all()
rows: list[dict] = []
for product in products:
result = calculate_product_cost(db, product.id)
finished = result.get("finished_product_delivered") or 0.0
product_series.append(finished)
rows.append(
{
"id": product.id,
"product_name": result["product_name"],
"client_name": result["client_name"],
"finished_product_delivered": finished,
"warnings": result["warnings"],
}
)
rows.sort(key=lambda row: row["finished_product_delivered"], reverse=True)
products_summary = {
"count": len(products),
"top": rows[0] if rows else None,
"top_products": rows[:4],
}
return {
"raw_materials": raw_materials_summary,
"mixes": mixes_summary,
"products": products_summary,
# Pre-computed numeric series for the homepage trend chart so the
# client doesn't need full collections to draw it.
"trend_seeds": {
"raw_material_cost_per_kg": raw_series,
"mix_cost_per_kg": mix_series,
"product_finished_delivered": product_series,
},
}
+68 -1
View File
@@ -7,8 +7,15 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.access import (
INTERNAL_USER_SUBJECT,
INTERNAL_USER_TENANT_ID,
get_user_permissions,
permissions_to_module_map,
)
from app.core.security import verify_token
from app.db.session import get_db
from app.models.access import Role, User
from app.models.client_access import ClientFeatureAccess, ClientUser
from app.services.client_access_service import has_access_level, module_access_map
@@ -27,11 +34,52 @@ class AuthSession:
module_permissions: dict[str, str] | None = None
def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession:
def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession:
"""Translate an internal-user token into an AuthSession the shared route
dependencies can consume. Internal users present `role="internal"` so
`require_client_module_access` can spot them and skip the ClientUser DB
lookup, deriving their module_permissions from the role-permission table.
"""
user_id = payload.get("user_id")
if not isinstance(user_id, int):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
user = db.scalar(
select(User)
.where(User.id == user_id)
.options(selectinload(User.role).selectinload(Role.permissions))
)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive")
return AuthSession(
role="internal",
email=user.email,
name=user.name,
tenant_id=INTERNAL_USER_TENANT_ID,
client_role=user.role.name if user.role else None,
user_id=user.id,
client_account_id=None,
module_permissions=permissions_to_module_map(get_user_permissions(user)),
)
def get_auth_session(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> AuthSession:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
# Internal Hunter Stock Feeds users get an auth session derived from the
# role/permission tables rather than the client-portal ClientUser tables.
if payload.get("sub") == INTERNAL_USER_SUBJECT:
return _build_internal_auth_session(db, payload)
return AuthSession(
role=str(payload.get("role", "")),
email=str(payload.get("email", "")),
@@ -45,6 +93,13 @@ def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(
def require_client_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
# Internal Hunter Stock Feeds users share the workspace with client users
# but don't have a ClientUser row, so we accept them here and let
# `require_client_module_access` enforce the per-module checks.
if session.role == "internal":
if not session.tenant_id or not session.user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Internal user context is missing")
return session
if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required")
if not session.tenant_id:
@@ -82,6 +137,18 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db),
) -> AuthSession:
# Internal users have their permissions baked into the AuthSession at
# token-resolve time; skip the ClientUser/feature DB lookups and check
# the in-memory module_permissions map directly.
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get(module_key), minimum_level):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"{module_key} access is not permitted",
)
return session
user = load_current_client_user(db, session)
feature = db.scalar(
select(ClientFeatureAccess).where(
+193
View File
@@ -0,0 +1,193 @@
"""Database-driven role/permission access control.
This module is the single source of truth for permission enforcement on
internal Hunter Stock Feeds users. Route handlers should depend on
``require_permission`` / ``require_any_permission`` / ``require_all_permissions``
rather than checking role names. To change access later, update the database
seed (``app.seed_access``) — no route code should need to change.
Fail-closed rules enforced here:
* Missing or invalid token -> 401
* Unknown user_id -> 401
* Inactive user -> 403
* No role / missing perm -> 403
"""
from __future__ import annotations
from typing import Iterable
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.security import verify_token
from app.db.session import get_db
from app.models.access import Permission, Role, User
bearer_scheme = HTTPBearer(auto_error=False)
# Subject claim used by tokens issued for internal Hunter Stock Feeds users.
# Distinct from the existing client-portal/admin tokens so the two systems
# cannot impersonate each other.
INTERNAL_USER_SUBJECT = "internal_user"
# Default tenant internal Hunter Stock Feeds users operate against. Internal
# users see the production workspace data, so they read/write the same tenant
# the costing seed populates.
INTERNAL_USER_TENANT_ID = "hunter-premium-produce"
# Maps the internal permission keys onto the legacy module/access-level shape
# the existing client-portal routes already understand. This lets the shared
# routes (raw materials, mixes, products, etc.) accept either token type
# without having to be rewritten.
_PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
"view_dashboard": ("dashboard", "view"),
"view_mix_calculator": ("mix_calculator", "view"),
"use_mix_calculator": ("mix_calculator", "edit"),
"save_mix_calculator_session": ("mix_calculator", "edit"),
"view_raw_materials": ("raw_materials", "view"),
"edit_raw_materials": ("raw_materials", "edit"),
"view_products": ("products", "view"),
"edit_products": ("products", "edit"),
"view_mixes": ("mix_master", "view"),
"edit_mixes": ("mix_master", "edit"),
# Admin-only permissions (view_users, manage_users, manage_permissions,
# view_settings, edit_settings) are intentionally excluded — they don't
# correspond to any of the legacy module keys and remain accessible only
# via the explicit `require_permission(...)` dependency.
}
_ACCESS_LEVEL_RANK = {"none": 0, "view": 1, "edit": 2, "manage": 3}
def permissions_to_module_map(permission_keys: set[str]) -> dict[str, str]:
"""Translate internal permission keys into the legacy module→level map.
Higher levels (edit > view) win when multiple permissions map to the same
module key (e.g. ``edit_raw_materials`` overrides ``view_raw_materials``).
"""
result: dict[str, str] = {}
for key in permission_keys:
mapping = _PERMISSION_TO_MODULE_LEVEL.get(key)
if not mapping:
continue
module_key, level = mapping
if _ACCESS_LEVEL_RANK[level] > _ACCESS_LEVEL_RANK.get(result.get(module_key, "none"), 0):
result[module_key] = level
return result
def get_user_permissions(user: User | None) -> set[str]:
"""Return the set of permission keys granted to ``user``.
Returns an empty set for unknown users, inactive users, or users without
a role. This means downstream checks fail closed by default.
"""
if user is None or not user.is_active or user.role is None:
return set()
return {permission.key for permission in user.role.permissions}
def user_has_permission(user: User | None, permission_key: str) -> bool:
return permission_key in get_user_permissions(user)
def _load_user(db: Session, user_id: int) -> User | None:
return db.scalar(
select(User)
.where(User.id == user_id)
.options(selectinload(User.role).selectinload(Role.permissions))
)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> User:
"""Resolve the current internal user from the bearer token.
Raises 401 for missing/invalid tokens or unknown users, 403 for inactive
users.
"""
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials)
if payload.get("sub") != INTERNAL_USER_SUBJECT:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
user_id = payload.get("user_id")
if not isinstance(user_id, int):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
user = _load_user(db, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is inactive")
return user
def require_permission(permission_key: str):
"""FastAPI dependency factory enforcing a single permission key."""
def dependency(user: User = Depends(get_current_user)) -> User:
if not user_has_permission(user, permission_key):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permission: {permission_key}",
)
return user
return dependency
def require_any_permission(permission_keys: Iterable[str]):
"""FastAPI dependency factory: pass if the user has any of the keys."""
keys = tuple(permission_keys)
def dependency(user: User = Depends(get_current_user)) -> User:
granted = get_user_permissions(user)
if not any(key in granted for key in keys):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires any of: {list(keys)}",
)
return user
return dependency
def require_all_permissions(permission_keys: Iterable[str]):
"""FastAPI dependency factory: pass only if the user has every key."""
keys = tuple(permission_keys)
def dependency(user: User = Depends(get_current_user)) -> User:
granted = get_user_permissions(user)
missing = [key for key in keys if key not in granted]
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {missing}",
)
return user
return dependency
__all__ = [
"INTERNAL_USER_SUBJECT",
"Permission",
"Role",
"User",
"bearer_scheme",
"get_current_user",
"get_user_permissions",
"require_all_permissions",
"require_any_permission",
"require_permission",
"user_has_permission",
]
+38
View File
@@ -48,3 +48,41 @@ def verify_token(token: str) -> dict[str, Any]:
if int(payload.get("exp", 0)) < int(time.time()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication token has expired")
return payload
# --- Password hashing ------------------------------------------------------
# PBKDF2-SHA256 with a per-password 16-byte salt and 200k iterations. Stored
# as `pbkdf2_sha256$iterations$salt_hex$hash_hex`. No external dep needed.
import os
import secrets
_PBKDF2_ITERATIONS = 200_000
_PBKDF2_ALGO = "pbkdf2_sha256"
def hash_password(password: str) -> str:
if not password:
raise ValueError("Password cannot be empty")
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITERATIONS)
return f"{_PBKDF2_ALGO}${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded or not password:
return False
try:
algo, iterations_str, salt_hex, digest_hex = encoded.split("$", 3)
except ValueError:
return False
if algo != _PBKDF2_ALGO:
return False
try:
iterations = int(iterations_str)
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(digest_hex)
except ValueError:
return False
candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(candidate, expected)
+4
View File
@@ -12,8 +12,10 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from app.api.access import router as access_router
from app.api.auth import router as auth_router
from app.api.client_access import router as client_access_router
from app.api.dashboard import router as dashboard_router
from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router
@@ -72,7 +74,9 @@ app.add_middleware(
)
app.include_router(auth_router)
app.include_router(access_router)
app.include_router(client_access_router)
app.include_router(dashboard_router)
app.include_router(raw_materials_router)
app.include_router(mixes_router)
app.include_router(mix_calculator_router)
+5
View File
@@ -1,3 +1,4 @@
from app.models.access import Permission, Role, User, role_permissions
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
@@ -19,9 +20,13 @@ __all__ = [
"MixCalculatorSessionLine",
"MixIngredient",
"PackagingCostRule",
"Permission",
"ProcessCostRule",
"Product",
"RawMaterial",
"RawMaterialPriceVersion",
"Role",
"Scenario",
"User",
"role_permissions",
]
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Table, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("permission_id", ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
)
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: 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)
permissions: Mapped[list["Permission"]] = relationship(
secondary=role_permissions,
back_populates="roles",
lazy="selectin",
)
users: Mapped[list["User"]] = relationship(back_populates="role")
class Permission(Base):
__tablename__ = "permissions"
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(128), unique=True, index=True)
description: 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)
roles: Mapped[list["Role"]] = relationship(
secondary=role_permissions,
back_populates="permissions",
)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
role_id: Mapped[int | None] = mapped_column(ForeignKey("roles.id"), nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Per-user password hash (PBKDF2-SHA256). Null while a user has never set
# a personal password — they can still sign in with the shared internal
# password until they choose one in settings.
password_hash: Mapped[str | None] = mapped_column(String(255), 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)
role: Mapped["Role | None"] = relationship(back_populates="users", lazy="selectin")
+2
View File
@@ -15,6 +15,7 @@ from app.models.client_access import ClientAccessAuditEvent, ClientAccount, Clie
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.seed_access import seed_access
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
@@ -688,6 +689,7 @@ def seed_if_empty():
else:
logger.warning("Skipping costing workspace seed because workbook is missing at %s", WORKBOOK_PATH)
seed_client_access(db)
seed_access(db)
db.commit()
+164
View File
@@ -0,0 +1,164 @@
"""Idempotent seed for roles, permissions, and the Hunter Stock Feeds users.
Re-running this is safe: it upserts permissions, syncs each role's permission
set to the declared list, and creates or updates the seed users without
duplicating rows. Permission grants are the source of truth — change them
here (or in the DB) rather than in route code.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models.access import Permission, Role, User
PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
("view_dashboard", "View the operational dashboard"),
("view_mix_calculator", "View the mix calculator area"),
("use_mix_calculator", "Run calculations in the mix calculator"),
("save_mix_calculator_session", "Save mix calculator sessions"),
("view_raw_materials", "View raw materials"),
("edit_raw_materials", "Create and edit raw materials"),
("view_products", "View finished products"),
("edit_products", "Create and edit finished products"),
("view_mixes", "View mix master recipes"),
("edit_mixes", "Create and edit mix master recipes"),
("view_users", "View internal users and roles"),
("manage_users", "Create, deactivate, and assign user roles"),
("manage_permissions", "Modify roles and role-permission assignments"),
("view_settings", "View system settings"),
("edit_settings", "Edit system settings"),
)
ROLE_DEFINITIONS: dict[str, dict] = {
"Admin": {
"description": "Full administrative access including user and permission management.",
"permissions": [
"view_dashboard",
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_raw_materials",
"edit_raw_materials",
"view_products",
"view_mixes",
"view_users",
"manage_users",
"manage_permissions",
"view_settings",
"edit_settings",
],
},
"Operations": {
"description": "Mix calculator only — cannot edit raw materials, products, mixes, users, or settings.",
"permissions": [
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
],
},
"Full Access": {
"description": "Operational data editor — cannot manage users or permissions unless explicitly granted.",
"permissions": [
"view_dashboard",
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_raw_materials",
"edit_raw_materials",
"view_products",
"edit_products",
"view_mixes",
"edit_mixes",
],
},
}
SEED_USERS: tuple[dict, ...] = (
{
"email": "admin@hunterstockfeeds.com",
"name": "Hunter Stock Feeds Admin",
"role": "Admin",
},
{
"email": "ops@hunterstockfeeds.com",
"name": "Hunter Stock Feeds Operations",
"role": "Operations",
},
{
"email": "craig@hunterstockfeeds.com",
"name": "Craig",
"role": "Full Access",
},
)
def _upsert_permissions(db: Session) -> dict[str, Permission]:
existing = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
for key, description in PERMISSION_DEFINITIONS:
permission = existing.get(key)
if permission is None:
permission = Permission(key=key, description=description)
db.add(permission)
existing[key] = permission
elif permission.description != description:
permission.description = description
db.flush()
return existing
def _upsert_roles(db: Session, permissions_by_key: dict[str, Permission]) -> dict[str, Role]:
existing = {
role.name: role
for role in db.scalars(
select(Role).options(selectinload(Role.permissions))
).all()
}
for role_name, definition in ROLE_DEFINITIONS.items():
role = existing.get(role_name)
if role is None:
role = Role(name=role_name, description=definition["description"])
db.add(role)
existing[role_name] = role
elif role.description != definition["description"]:
role.description = definition["description"]
db.flush()
desired = {permissions_by_key[key] for key in definition["permissions"]}
current = set(role.permissions)
for permission in desired - current:
role.permissions.append(permission)
for permission in current - desired:
role.permissions.remove(permission)
db.flush()
return existing
def _upsert_users(db: Session, roles_by_name: dict[str, Role]) -> None:
existing = {user.email: user for user in db.scalars(select(User)).all()}
for entry in SEED_USERS:
email = entry["email"].lower()
role = roles_by_name[entry["role"]]
user = existing.get(email)
if user is None:
user = User(email=email, name=entry["name"], role_id=role.id, is_active=True)
db.add(user)
existing[email] = user
else:
user.name = entry["name"]
user.role_id = role.id
if not user.is_active:
user.is_active = True
db.flush()
def seed_access(db: Session) -> None:
"""Idempotent: roles, permissions, role-permission links, seed users."""
permissions_by_key = _upsert_permissions(db)
roles_by_name = _upsert_roles(db, permissions_by_key)
_upsert_users(db, roles_by_name)
+28 -18
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession
from app.models.mix import Mix, MixIngredient
@@ -108,29 +108,39 @@ def calculate_mix_calculator_preview(
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
# Aggregate mix totals in a single query instead of loading every
# ingredient row for every product. The previous implementation was the
# main slow path on first Mix Calculator open — it streamed the entire
# tenant's recipe table just to compute one sum per product.
mix_totals_rows = db.execute(
select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0))
.join(Mix, Mix.id == MixIngredient.mix_id)
.where(Mix.tenant_id == tenant_id)
.group_by(MixIngredient.mix_id)
).all()
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows}
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id)
.options(selectinload(Product.mix).selectinload(Mix.ingredients))
.options(joinedload(Product.mix))
.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,
}
)
product_rows = [
{
"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_totals.get(product.mix_id, 0.0),
}
for product in products
]
return {"clients": clients, "products": product_rows}
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.access import (
INTERNAL_USER_SUBJECT,
get_user_permissions,
require_all_permissions,
require_any_permission,
require_permission,
user_has_permission,
)
from app.core.security import issue_token
from app.db.session import Base, get_db
from app.models.access import Permission, Role, User
from app.seed_access import PERMISSION_DEFINITIONS, ROLE_DEFINITIONS, SEED_USERS, seed_access
def _build_session() -> Session:
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
# --- Seed + permission lookup ----------------------------------------------
def test_seed_creates_roles_permissions_and_users():
db = _build_session()
seed_access(db)
db.commit()
assert {role.name for role in db.query(Role).all()} == set(ROLE_DEFINITIONS.keys())
assert {p.key for p in db.query(Permission).all()} == {key for key, _ in PERMISSION_DEFINITIONS}
assert {user.email for user in db.query(User).all()} == {entry["email"] for entry in SEED_USERS}
def test_seed_is_idempotent():
db = _build_session()
seed_access(db)
db.commit()
seed_access(db)
db.commit()
assert db.query(User).count() == len(SEED_USERS)
assert db.query(Role).count() == len(ROLE_DEFINITIONS)
assert db.query(Permission).count() == len(PERMISSION_DEFINITIONS)
def test_admin_role_permissions_match_spec():
db = _build_session()
seed_access(db)
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
granted = get_user_permissions(admin)
assert granted == set(ROLE_DEFINITIONS["Admin"]["permissions"])
assert "manage_users" in granted
assert "manage_permissions" in granted
# Admin spec deliberately excludes edit_products / edit_mixes.
assert "edit_products" not in granted
assert "edit_mixes" not in granted
def test_operations_role_is_mix_calculator_only():
db = _build_session()
seed_access(db)
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
granted = get_user_permissions(ops)
assert granted == {"view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"}
assert not user_has_permission(ops, "edit_raw_materials")
assert not user_has_permission(ops, "view_dashboard")
assert not user_has_permission(ops, "manage_users")
def test_full_access_role_can_edit_operational_data_but_not_users():
db = _build_session()
seed_access(db)
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
granted = get_user_permissions(craig)
assert {"edit_raw_materials", "edit_products", "edit_mixes", "save_mix_calculator_session"} <= granted
assert "manage_users" not in granted
assert "manage_permissions" not in granted
def test_inactive_user_has_no_permissions():
db = _build_session()
seed_access(db)
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
admin.is_active = False
assert get_user_permissions(admin) == set()
assert not user_has_permission(admin, "view_dashboard")
def test_unknown_user_has_no_permissions():
assert get_user_permissions(None) == set()
assert not user_has_permission(None, "view_dashboard")
def test_user_without_role_has_no_permissions():
db = _build_session()
seed_access(db)
orphan = User(email="nobody@hunterstockfeeds.com", name="Nobody", role_id=None, is_active=True)
db.add(orphan)
db.flush()
assert get_user_permissions(orphan) == set()
# --- Route-level enforcement -----------------------------------------------
@pytest.fixture()
def app_and_db():
db = _build_session()
seed_access(db)
db.commit()
app = FastAPI()
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
@app.get("/needs-edit-raw")
def needs_edit_raw(_: User = Depends(require_permission("edit_raw_materials"))):
return {"ok": True}
@app.get("/needs-any")
def needs_any(_: User = Depends(require_any_permission(["edit_raw_materials", "manage_users"]))):
return {"ok": True}
@app.get("/needs-all")
def needs_all(_: User = Depends(require_all_permissions(["view_raw_materials", "edit_raw_materials"]))):
return {"ok": True}
return TestClient(app), db
def _token_for(user: User) -> str:
return issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
def test_route_allows_user_with_permission(app_and_db):
client, db = app_and_db
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {_token_for(craig)}"})
assert response.status_code == 200
def test_route_denies_user_without_permission(app_and_db):
client, db = app_and_db
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {_token_for(ops)}"})
assert response.status_code == 403
assert "edit_raw_materials" in response.json()["detail"]
def test_route_denies_inactive_user(app_and_db):
client, db = app_and_db
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
craig.is_active = False
db.commit()
response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {_token_for(craig)}"})
assert response.status_code == 403
def test_route_denies_missing_token(app_and_db):
client, _ = app_and_db
response = client.get("/needs-edit-raw")
assert response.status_code == 401
def test_route_denies_token_with_wrong_subject(app_and_db):
client, db = app_and_db
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
forged = issue_token({"sub": "client", "user_id": craig.id})
response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {forged}"})
assert response.status_code == 401
def test_route_denies_unknown_user_id(app_and_db):
client, _ = app_and_db
forged = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": 999_999})
response = client.get("/needs-edit-raw", headers={"Authorization": f"Bearer {forged}"})
assert response.status_code == 401
def test_require_any_permission_passes_with_one_match(app_and_db):
client, db = app_and_db
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one() # has edit_raw_materials
response = client.get("/needs-any", headers={"Authorization": f"Bearer {_token_for(craig)}"})
assert response.status_code == 200
def test_require_any_permission_denies_when_none_match(app_and_db):
client, db = app_and_db
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
response = client.get("/needs-any", headers={"Authorization": f"Bearer {_token_for(ops)}"})
assert response.status_code == 403
def test_require_all_permissions(app_and_db):
client, db = app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
ok = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(admin)}"})
assert ok.status_code == 200
denied = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(ops)}"})
assert denied.status_code == 403
+12 -20
View File
@@ -7,6 +7,9 @@
"": {
"name": "data-entry-app-frontend",
"version": "0.1.5",
"dependencies": {
"lucide-svelte": "^1.0.1"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.2.0",
"@sveltejs/adapter-node": "^5.2.12",
@@ -55,7 +58,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -66,7 +68,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -77,7 +78,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -87,14 +87,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -882,7 +880,6 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1020,7 +1017,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/resolve": {
@@ -1034,7 +1030,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
@@ -1154,7 +1149,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1167,7 +1161,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1187,7 +1180,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1207,7 +1199,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -1261,7 +1252,6 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-errors": {
@@ -1285,14 +1275,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -1420,7 +1408,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -1701,14 +1688,21 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/lucide-svelte": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz",
"integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==",
"license": "ISC",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -1988,7 +1982,6 @@
"version": "5.55.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -2298,7 +2291,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}
+3
View File
@@ -17,5 +17,8 @@
"typescript": "^5.5.4",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"dependencies": {
"lucide-svelte": "^1.0.1"
}
}
+95 -10
View File
@@ -14,6 +14,7 @@ import {
import type {
ClientAccessAccount,
ClientAccessPowerBiExport,
DashboardSummary,
ClientUserCreateInput,
ClientUserModulePermission,
ClientUserUpdateInput,
@@ -125,6 +126,62 @@ async function fetchJson<T>(path: string, fallback: T, auth: AuthMode = 'none',
}
}
// In-memory GET cache with TTL + in-flight de-duplication. The cache key
// includes the auth-mode and last 8 chars of the bearer token so different
// sessions can't read each other's entries. Any mutation calls clearApiCache()
// to invalidate. Memory footprint is bounded by entries naturally aging out.
type CacheEntry = { value: unknown; expiresAt: number };
const responseCache = new Map<string, CacheEntry>();
const inflightRequests = new Map<string, Promise<unknown>>();
const READ_CACHE_TTL_MS = 30_000;
function makeCacheKey(path: string, auth: AuthMode) {
const token = browser ? getToken(auth) ?? '' : '';
return `${auth}:${token.slice(-8)}:${path}`;
}
async function cachedFetchJson<T>(
path: string,
fallback: T,
auth: AuthMode = 'none',
fetcher: ApiFetch = fetch
): Promise<T> {
// Bypass the cache during SSR (no localStorage, no shared session).
if (!browser) {
return fetchJson<T>(path, fallback, auth, fetcher);
}
const key = makeCacheKey(path, auth);
const now = Date.now();
const cached = responseCache.get(key);
if (cached && cached.expiresAt > now) {
return cached.value as T;
}
// De-duplicate concurrent callers (e.g. two effects firing the same load).
const existing = inflightRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const promise = fetchJson<T>(path, fallback, auth, fetcher)
.then((value) => {
responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS });
return value;
})
.finally(() => {
inflightRequests.delete(key);
});
inflightRequests.set(key, promise);
return promise;
}
export function clearApiCache() {
responseCache.clear();
inflightRequests.clear();
}
async function request<T>(
path: string,
options: RequestInit,
@@ -155,6 +212,12 @@ async function request<T>(
throw new Error(message);
}
const isMutation = !!options.method && options.method.toUpperCase() !== 'GET';
if (isMutation && browser) {
// Mutations invalidate cached reads — keeps Dashboard / lists fresh
// after the user creates or updates anything.
clearApiCache();
}
return (await response.json()) as T;
} catch (error) {
throw normalizeRequestError(error);
@@ -162,13 +225,13 @@ async function request<T>(
}
export const api = {
rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher),
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', mockMixes, 'client', fetcher),
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
cachedFetchJson<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) =>
@@ -186,19 +249,41 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
products: (fetcher?: ApiFetch) => fetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
productCosts: (fetcher?: ApiFetch) =>
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
clientAccessExport: (fetcher?: ApiFetch) =>
fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher),
dashboardSummary: (fetcher?: ApiFetch) =>
cachedFetchJson<DashboardSummary>(
'/api/dashboard/summary',
{
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
},
'client',
fetcher
),
clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
// Internal Hunter Stock Feeds login. Returns the same LoginResponse shape
// (with `permissions` populated) so the existing client-session store can
// consume it directly.
internalLogin: (email: string, password: string) =>
request<LoginResponse>('/api/access/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
internalSession: (fetcher?: ApiFetch) =>
request<LoginResponse>('/api/access/me', { method: 'GET' }, 'client', fetcher),
adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', {
method: 'POST',
+284 -248
View File
@@ -7,6 +7,24 @@
import { featureFlags } from '$lib/features';
import { onMount, tick } from 'svelte';
import packageInfo from '../../../package.json';
import {
LayoutDashboard,
Calculator,
Wheat,
FlaskConical,
Boxes,
Workflow,
ShieldCheck,
DollarSign,
ClipboardList,
Search,
LogOut,
Plus,
Settings,
ChevronDown,
Menu
} from 'lucide-svelte';
import type { ComponentType } from 'svelte';
type SearchItem = {
href: string;
@@ -19,29 +37,31 @@
href: string;
label: string;
shortLabel: string;
icon?: 'home';
icon: ComponentType;
moduleKey?: string;
};
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' };
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: LayoutDashboard, moduleKey: 'dashboard' };
const mixCalculatorItem: NavItem = {
href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new',
label: 'Mix Calculator',
shortLabel: 'MC',
icon: Calculator,
moduleKey: 'mix_calculator'
};
const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' },
{ href: '/products', label: 'Products', shortLabel: 'PR', moduleKey: 'products' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' }
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
{ href: '/products', label: 'Products', shortLabel: 'PR', icon: Boxes, moduleKey: 'products' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', icon: Workflow, 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', icon: ShieldCheck, moduleKey: 'client_access' };
const navigation = [dashboardItem, mixCalculatorItem, ...workingDocumentItems, accessControlItem];
const footerLinks = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
type FooterLink = { href: string; label: string; shortLabel: string; icon: ComponentType };
const footerLinks: FooterLink[] = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP', icon: DollarSign },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV', icon: ClipboardList }
];
const baseSearchItems: SearchItem[] = [
@@ -140,8 +160,8 @@
...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
? []
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel }])
]);
: [{ href: accessControlItem.href, label: accessControlItem.label, shortLabel: accessControlItem.shortLabel, icon: accessControlItem.icon }])
] as FooterLink[]);
const primaryBottomNavigation = $derived(
[
...(visibleDashboardItem ? [visibleDashboardItem] : []),
@@ -162,25 +182,44 @@
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
}
function pageDescription(pathname: string) {
if (pathname.startsWith('/mix-calculator/')) {
return 'Review a saved mix calculation session and prepare a printable output';
type Crumb = { label: string; href?: string };
// Breadcrumbs replace the previous descriptive subtitle. They give every
// page the same shape — Workspace Section Subpage — so headings feel
// consistent across the app instead of each page improvising its own copy.
function breadcrumbs(pathname: string): Crumb[] {
const root: Crumb = { label: 'Workspace', href: '/' };
if (pathname === '/') {
return [root, { label: 'Dashboard' }];
}
const descriptions: Record<string, string> = {
'/': 'Hunter Premium Produce client workspace',
'/raw-materials': 'Review source input costs and downstream exposure',
'/mixes': 'Browse saved mix worksheets and costing outputs',
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
'/mix-calculator': 'Create and review client-specific mix calculation sessions',
'/mix-calculator/new': 'Create a new client-specific mix calculation session',
'/products': 'Track delivered product pricing and margin views',
'/settings': 'Review your workspace profile and application settings',
'/scenarios': 'Compare alternate pricing and production assumptions',
'/client-access': 'Manage user access, module permissions, and audit history'
};
if (pathname.startsWith('/mix-calculator')) {
const trail: Crumb[] = [root, { label: 'Mix Calculator', href: '/mix-calculator' }];
if (pathname === '/mix-calculator/new') trail.push({ label: 'New Session' });
else if (pathname.endsWith('/print')) trail.push({ label: 'Print' });
else if (pathname !== '/mix-calculator') trail.push({ label: 'Session' });
return trail;
}
return descriptions[pathname] ?? 'Hunter Premium Produce client workspace';
if (pathname.startsWith('/mixes')) {
const trail: Crumb[] = [root, { label: 'Mix Master', href: '/mixes' }];
if (pathname === '/mixes/new') trail.push({ label: 'New Mix' });
else if (pathname !== '/mixes') trail.push({ label: 'Detail' });
return trail;
}
const sectionMap: Record<string, string> = {
'/raw-materials': 'Raw Materials',
'/products': 'Products',
'/scenarios': 'Scenarios',
'/client-access': 'Client Access',
'/settings': 'Settings'
};
const section = sectionMap[pathname];
if (section) return [root, { label: section }];
return [root, { label: pageTitle(pathname) }];
}
function openPalette(query = '') {
@@ -255,10 +294,16 @@
restoredToken = token;
isRestoringSession = true;
api.clientSession()
// Internal Hunter Stock Feeds users are refreshed against /api/access/me;
// legacy client-portal users keep using /api/auth/client/session.
const refresh = $clientSession?.role === 'internal' ? api.internalSession() : api.clientSession();
refresh
.then((session) => {
restoredToken = session.token;
clientSession.set(session);
// /api/access/me does not re-issue a token; preserve the existing one.
const nextToken = session.token ?? token;
restoredToken = nextToken;
clientSession.set({ ...session, token: nextToken });
return invalidateAll();
})
.catch(() => {
@@ -270,10 +315,14 @@
});
});
// Search palette items are seeded lazily — three list endpoints worth of
// data only when the user actually opens the palette, not on every login or
// navigation. Subsequent opens hit the api.ts cache.
$effect(() => {
const hydrated = $sessionHydrated;
const session = $clientSession;
const token = session?.token ?? null;
const shouldSeed = paletteOpen;
if (!hydrated || !session || !token) {
seededSearchItems = [];
@@ -281,7 +330,7 @@
return;
}
if (seededSearchToken === token) {
if (!shouldSeed || seededSearchToken === token) {
return;
}
@@ -398,90 +447,65 @@
</div>
<div class="sidebar-body">
<p class="nav-section-label">Workspace</p>
<nav class="nav-list" aria-label="Client navigation">
{#if visibleDashboardItem}
{@const Icon = visibleDashboardItem.icon}
<a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href}>
<span class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M3.75 10.5 12 3.75l8.25 6.75"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.25 9.75v9h13.5v-9"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10.125 18.75v-5.25h3.75v5.25"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleDashboardItem.label}</span>
</a>
{/if}
{#if visibleMixCalculatorItem}
{@const Icon = visibleMixCalculatorItem.icon}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href}>
<span class="nav-icon">
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
</span>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
</nav>
<div class="nav-group" aria-label="Working documents" hidden={!visibleWorkingDocumentItems.length}>
<button
aria-controls="working-documents-nav"
aria-expanded={workingDocumentsExpanded}
class:active={workingDocumentsActive}
class="nav-group-toggle"
type="button"
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
>
<span class="nav-group-toggle-copy">
<span class="nav-icon muted">WD</span>
<span>Working Docs</span>
</span>
<span class:open={workingDocumentsExpanded} class="chevron"></span>
</button>
{#if workingDocumentsExpanded}
<nav class="nav-sublist" id="working-documents-nav" aria-label="Working document pages">
{#each visibleWorkingDocumentItems as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
{/if}
</div>
{#if visibleWorkingDocumentItems.length}
<p class="nav-section-label">Working Docs</p>
<nav class="nav-list" aria-label="Working document pages">
{#each visibleWorkingDocumentItems as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</a>
{/each}
</nav>
{/if}
<div class="sidebar-footer">
{#each visibleFooterLinks as item}
<a href={item.href}>
<span class="nav-icon muted">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
{#if visibleFooterLinks.length}
<p class="nav-section-label">More</p>
<nav class="nav-list" aria-label="Workspace shortcuts">
{#each visibleFooterLinks as item}
{@const Icon = item.icon}
<a href={item.href}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</a>
{/each}
</nav>
{/if}
<div class="sidebar-meta">
<span class="sidebar-version-row">
<span>{appVersion}</span>
<span class="release-pill">{releaseStage}</span>
</span>
<small>&copy; {currentYear} Hunter Premium Produce</small>
{#if $clientSession}
<button class="sidebar-signout" type="button" onclick={() => clientSession.clear()}>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Sign out</span>
</button>
{/if}
<div class="sidebar-meta-foot">
<span class="version-pill">
<span>{appVersion}</span>
<span class="release-pill">{releaseStage}</span>
</span>
<small>&copy; {currentYear} Hunter Premium Produce</small>
</div>
</div>
</div>
</aside>
@@ -491,8 +515,17 @@
<header class="topbar">
<div class="topbar-start">
<div class="topbar-copy">
<nav class="breadcrumbs" aria-label="Breadcrumb">
{#each breadcrumbs(page.url.pathname) as crumb, index}
{#if index > 0}<span class="breadcrumb-sep" aria-hidden="true">/</span>{/if}
{#if crumb.href && index < breadcrumbs(page.url.pathname).length - 1}
<a href={crumb.href}>{crumb.label}</a>
{:else}
<span aria-current={index === breadcrumbs(page.url.pathname).length - 1 ? 'page' : undefined}>{crumb.label}</span>
{/if}
{/each}
</nav>
<h1>{pageTitle(page.url.pathname)}</h1>
<p>{pageDescription(page.url.pathname)}</p>
</div>
</div>
@@ -599,42 +632,15 @@
{#if showBottomNav}
<nav class="bottom-nav" aria-label="Tablet navigation">
{#each primaryBottomNavigation as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="bottom-nav-icon">
{#if item.icon === 'home'}
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M3.75 10.5 12 3.75l8.25 6.75"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.25 9.75v9h13.5v-9"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10.125 18.75v-5.25h3.75v5.25"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else}
{item.shortLabel}
{/if}
</span>
<span class="bottom-nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
<span>{item.label}</span>
</a>
{/each}
<button aria-expanded={navOpen} class:active={navOpen} type="button" onclick={() => (navOpen = !navOpen)}>
<span class="bottom-nav-icon muted">+</span>
<span class="bottom-nav-icon"><Menu size={18} strokeWidth={1.85} /></span>
<span>More</span>
</button>
</nav>
@@ -668,97 +674,58 @@
<div class="drawer-grid">
<nav class="drawer-section" aria-label="All workspace pages">
{#if visibleDashboardItem}
{@const Icon = visibleDashboardItem.icon}
<a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M3.75 10.5 12 3.75l8.25 6.75"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M5.25 9.75v9h13.5v-9"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10.125 18.75v-5.25h3.75v5.25"
stroke="currentColor"
stroke-width="1.85"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleDashboardItem.label}</span>
</a>
{/if}
{#if visibleMixCalculatorItem}
{@const Icon = visibleMixCalculatorItem.icon}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
</span>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
<div class="drawer-group" hidden={!visibleWorkingDocumentItems.length}>
<button
aria-controls="drawer-working-documents-nav"
aria-expanded={workingDocumentsExpanded}
class:active={workingDocumentsActive}
class="nav-group-toggle drawer-group-toggle"
type="button"
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
>
<span class="nav-group-toggle-copy">
<span class="nav-icon muted">WD</span>
<span>Working Documents</span>
</span>
<span class:open={workingDocumentsExpanded} class="chevron"></span>
</button>
{#if workingDocumentsExpanded}
<div id="drawer-working-documents-nav" class="drawer-sublist">
{#each visibleWorkingDocumentItems as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
{/if}
</div>
{#if visibleWorkingDocumentItems.length}
<div class="drawer-sublist" id="drawer-working-documents-nav">
{#each visibleWorkingDocumentItems as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</a>
{/each}
</div>
{/if}
</nav>
<div class="drawer-section drawer-actions">
<a href="/mixes/new" onclick={() => (navOpen = false)}>
<span class="nav-icon">NW</span>
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
<span>Create mix worksheet</span>
</a>
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}>
<span class="nav-icon">MC</span>
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
<span>Create mix session</span>
</a>
<button type="button" onclick={openSettings}>
<span class="nav-icon muted">ST</span>
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
<span>Change settings</span>
</button>
<a href="/products" onclick={() => (navOpen = false)}>
<span class="nav-icon muted">DP</span>
<span class="nav-icon"><DollarSign size={18} strokeWidth={1.75} /></span>
<span>Review delivered pricing</span>
</a>
<button type="button" onclick={() => openPalette('')}>
<span class="nav-icon muted">SR</span>
<span class="nav-icon"><Search size={18} strokeWidth={1.75} /></span>
<span>Search the workspace</span>
</button>
{#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}>
<span class="nav-icon muted">SO</span>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Sign out</span>
</button>
{/if}
@@ -871,7 +838,7 @@
.app-shell {
display: grid;
grid-template-columns: 244px minmax(0, 1fr);
grid-template-columns: 252px minmax(0, 1fr);
min-height: 100vh;
}
@@ -891,14 +858,15 @@
.sidebar {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 0.9rem;
gap: 0.4rem;
padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel);
border-right: 1px solid var(--line);
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
overflow-y: auto;
scrollbar-width: thin;
}
.sidebar-body {
@@ -906,7 +874,20 @@
display: flex;
flex: 1;
flex-direction: column;
gap: 1.1rem;
gap: 0.45rem;
}
.nav-section-label {
margin: 0.85rem 0.55rem 0.3rem;
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.nav-section-label:first-child {
margin-top: 0.25rem;
}
.brand-row {
@@ -914,6 +895,8 @@
align-items: center;
justify-content: space-between;
gap: 0.68rem;
padding: 0 0.25rem 0.4rem;
border-bottom: 1px solid var(--line);
}
.brand {
@@ -926,11 +909,21 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #6d7d74;
background: transparent;
border-radius: 0.55rem;
width: 1.6rem;
height: 1.6rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
transition: color 140ms ease, background-color 140ms ease;
}
.nav-list a:hover .nav-icon,
.nav-list a.active .nav-icon,
.sidebar-signout:hover .nav-icon {
color: var(--green-deep);
}
.nav-icon-mask {
@@ -1047,39 +1040,45 @@
font-size: 0.76rem;
}
.nav-list,
.nav-sublist,
.sidebar-footer {
.nav-list {
display: grid;
gap: 0.3rem;
gap: 0.12rem;
}
.nav-list a,
.nav-sublist a,
.sidebar-footer a {
.nav-list a {
position: relative;
display: flex;
align-items: center;
gap: 0.68rem;
padding: 0.72rem 0.68rem;
border-radius: 0.82rem;
color: #304038;
transition: background-color 160ms ease;
gap: 0.7rem;
padding: 0.6rem 0.6rem;
border-radius: 0.7rem;
color: #3a4a41;
font-size: 0.93rem;
transition: background-color 140ms ease, color 140ms ease;
}
.nav-list a:hover,
.nav-sublist a:hover,
.sidebar-footer a:hover,
.nav-list a.active,
.nav-sublist a.active {
.nav-list a:hover {
background: rgba(234, 248, 239, 0.55);
color: var(--green-deep);
}
.nav-list a.active {
background: var(--green-soft);
}
.nav-list a.active,
.nav-sublist a.active {
color: var(--green-deep);
font-weight: 600;
}
.nav-list a.active::before {
content: '';
position: absolute;
left: -0.85rem;
top: 0.45rem;
bottom: 0.45rem;
width: 3px;
border-radius: 999px;
background: var(--green-deep);
}
.nav-group {
display: grid;
gap: 0.55rem;
@@ -1138,45 +1137,62 @@
position: relative;
}
.nav-icon {
width: 1.56rem;
height: 1.56rem;
border-radius: 0.56rem;
}
.nav-icon svg,
.bottom-nav-icon svg {
width: 0.9rem;
height: 0.9rem;
}
/* `.nav-icon.muted` is kept for the bottom-nav (still uses letter labels). */
.nav-icon.muted {
color: #fff;
background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%);
}
.sidebar-footer {
margin-top: auto;
padding-top: 0.6rem;
flex-shrink: 0;
}
.sidebar-meta {
margin-top: auto;
display: grid;
gap: 0.2rem;
padding: 0.85rem 0.3rem 0;
color: var(--muted);
font-size: 0.78rem;
gap: 0.55rem;
padding-top: 0.85rem;
flex-shrink: 0;
}
.sidebar-meta small {
font-size: 0.74rem;
.sidebar-signout {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.6rem;
border: none;
border-radius: 0.7rem;
background: transparent;
color: #3a4a41;
font-size: 0.93rem;
text-align: left;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.sidebar-version-row {
.sidebar-signout:hover {
background: rgba(234, 248, 239, 0.55);
color: var(--green-deep);
}
.sidebar-meta-foot {
display: grid;
gap: 0.25rem;
padding: 0.7rem 0.55rem 0;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.76rem;
}
.sidebar-meta-foot small {
font-size: 0.72rem;
}
.version-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
gap: 0.45rem;
flex-wrap: wrap;
}
@@ -1218,20 +1234,40 @@
gap: 0.82rem;
}
.topbar-copy h1,
.topbar-copy p {
margin: 0;
}
.topbar-copy h1 {
font-size: 1.62rem;
margin: 0.18rem 0 0;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.topbar-copy p {
margin-top: 0.22rem;
.breadcrumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
color: var(--muted);
font-size: 0.92rem;
font-size: 0.78rem;
font-weight: 500;
}
.breadcrumbs a {
color: var(--muted);
transition: color 140ms ease;
}
.breadcrumbs a:hover {
color: var(--green-deep);
}
.breadcrumbs span[aria-current='page'] {
color: var(--text);
font-weight: 600;
}
.breadcrumb-sep {
color: #b9c5be;
font-size: 0.78rem;
}
.topbar-middle {
@@ -220,24 +220,16 @@
{/if}
</section>
{:else}
<section class="page-intro">
<div>
<p class="eyebrow">
<span class="eyebrow-icon" style="--button-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
<span>Mix Calculator</span>
</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">
{#if featureFlags.mixCalculatorSessionHistory || initialSession}
<section class="page-actions">
{#if featureFlags.mixCalculatorSessionHistory}
<a class="secondary-button" href="/mix-calculator">Session history</a>
{/if}
{#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
{/if}
</div>
</section>
</section>
{/if}
<section class="workspace-grid">
<article class="form-card">
@@ -556,11 +548,18 @@
mask: var(--button-icon-url) center / contain no-repeat;
}
.page-intro,
.page-actions,
.workspace-grid {
margin-bottom: 1.2rem;
}
.page-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
.page-intro {
display: flex;
align-items: flex-start;
@@ -0,0 +1,61 @@
<script lang="ts">
type Variant = 'line' | 'block' | 'circle' | 'pill';
type Props = {
variant?: Variant;
width?: string;
height?: string;
radius?: string;
class?: string;
};
let { variant = 'line', width, height, radius, class: className = '' }: Props = $props();
const defaults: Record<Variant, { width: string; height: string; radius: string }> = {
line: { width: '100%', height: '0.75rem', radius: '999px' },
block: { width: '100%', height: '6rem', radius: '0.95rem' },
circle: { width: '2.4rem', height: '2.4rem', radius: '999px' },
pill: { width: '5rem', height: '1.4rem', radius: '999px' }
};
const resolvedWidth = $derived(width ?? defaults[variant].width);
const resolvedHeight = $derived(height ?? defaults[variant].height);
const resolvedRadius = $derived(radius ?? defaults[variant].radius);
</script>
<span
aria-hidden="true"
class={`skeleton ${className}`}
style={`--sk-width:${resolvedWidth};--sk-height:${resolvedHeight};--sk-radius:${resolvedRadius};`}
></span>
<style>
.skeleton {
display: inline-block;
width: var(--sk-width);
height: var(--sk-height);
border-radius: var(--sk-radius);
background: linear-gradient(
90deg,
rgba(229, 236, 231, 0.55) 0%,
rgba(244, 247, 245, 0.92) 45%,
rgba(229, 236, 231, 0.55) 90%
)
0 0 / 220% 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
flex-shrink: 0;
vertical-align: middle;
}
@keyframes skeleton-shimmer {
0% { background-position: 220% 0; }
100% { background-position: -120% 0; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
background: rgba(229, 236, 231, 0.7);
}
}
</style>
+24
View File
@@ -11,6 +11,10 @@ export type AppSession = {
user_id?: number | null;
client_account_id?: number | null;
module_permissions?: Record<string, string>;
// Permission-key array, populated when the user signed in via the internal
// Hunter Stock Feeds /api/access/login endpoint. Drives feature gating.
permissions?: string[];
role_name?: string | null;
};
const ACCESS_LEVEL_ORDER: Record<string, number> = {
@@ -63,6 +67,9 @@ function createSessionStore(storageKey: string) {
clear() {
if (browser) {
localStorage.removeItem(storageKey);
// Drop any cached API responses keyed to the old session token.
// Imported lazily so this module stays free of api.ts side-effects.
import('$lib/api').then(({ clearApiCache }) => clearApiCache()).catch(() => {});
}
store.set(null);
}
@@ -102,6 +109,23 @@ export function hasModuleAccess(
return (ACCESS_LEVEL_ORDER[currentLevel] ?? 0) >= ACCESS_LEVEL_ORDER[minimumLevel];
}
// Permission-key check for the internal access-control system. Returns false
// for legacy sessions that don't carry a permissions array. UI gating only —
// every privileged backend route still enforces permissions itself.
export function hasPermission(session: AppSession | null | undefined, permissionKey: string) {
if (!session?.permissions) {
return false;
}
return session.permissions.includes(permissionKey);
}
export function hasAnyPermission(session: AppSession | null | undefined, permissionKeys: string[]) {
if (!session?.permissions) {
return false;
}
return permissionKeys.some((key) => session.permissions!.includes(key));
}
export const sessionHydrated = readable(false, (set) => {
if (!browser) {
return undefined;
+37
View File
@@ -298,6 +298,38 @@ export type ClientAccessPowerBiExport = {
clients: ClientAccessAccount[];
};
export type DashboardSummary = {
raw_materials: {
count: number;
total_market_value: number;
latest: { id: number; name: string; market_value: number; cost_per_kg: number; effective_date: string | null } | null;
} | null;
mixes: {
count: number;
average_cost_per_kg: number;
top: {
id: number;
name: string;
client_name: string;
ingredients_count: number;
total_mix_kg: number;
total_mix_cost: number;
mix_cost_per_kg: number | null;
warnings: string[];
} | null;
} | null;
products: {
count: number;
top: { id: number; product_name: string; client_name: string; finished_product_delivered: number; warnings: string[] } | null;
top_products: Array<{ id: number; product_name: string; client_name: string; finished_product_delivered: number; warnings: string[] }>;
} | null;
trend_seeds: {
raw_material_cost_per_kg: number[];
mix_cost_per_kg: number[];
product_finished_delivered: number[];
};
};
export type LoginResponse = {
name: string;
email: string;
@@ -308,6 +340,11 @@ export type LoginResponse = {
user_id?: number | null;
client_account_id?: number | null;
module_permissions?: Record<string, string>;
// Permission-key array populated by the internal Hunter Stock Feeds login.
// Drives permission-based UI gating; legacy client logins leave it undefined.
permissions?: string[];
// Display-friendly role label (e.g. "Admin", "Operations") when role === 'internal'.
role_name?: string | null;
};
export type RawMaterialCreateInput = {
+147 -96
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { api } from '$lib/api';
import { clientSession, sessionHydrated } from '$lib/session';
import type { Mix, ProductCostBreakdown, RawMaterial } from '$lib/types';
import Skeleton from '$lib/components/Skeleton.svelte';
import type { DashboardSummary } from '$lib/types';
import packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
type Segment = {
label: string;
@@ -44,7 +46,10 @@
isLoggingIn = true;
try {
const session = await api.clientLogin(email, password);
// Authenticates against the internal Hunter Stock Feeds role/permission
// system. The response is shape-compatible with the legacy client
// session, so the rest of the app continues to work unchanged.
const session = await api.internalLogin(email, password);
clientSession.set(session);
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
@@ -99,7 +104,8 @@
}).format(new Date(value));
}
function greetingForAst() {
// Australian Eastern time-of-day → greeting + matching Lucide icon.
function timeOfDay() {
const astHour = Number(
new Intl.DateTimeFormat('en-AU', {
hour: 'numeric',
@@ -108,34 +114,36 @@
}).format(new Date())
);
return astHour < 12 ? 'Good morning' : 'Good evening';
if (astHour >= 5 && astHour < 12) return { label: 'Good morning', icon: Sunrise, tone: 'morning' as const };
if (astHour >= 12 && astHour < 17) return { label: 'Good afternoon', icon: Sun, tone: 'afternoon' as const };
if (astHour >= 17 && astHour < 21) return { label: 'Good evening', icon: Sunset, tone: 'evening' as const };
return { label: 'Good evening', icon: Moon, tone: 'night' as const };
}
function firstName(name: string | null | undefined) {
return name?.trim().split(/\s+/)[0] ?? 'there';
}
function findHighestProduct(products: ProductCostBreakdown[]) {
return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0];
}
// The dashboard summary streams in after the route shell paints. Until it
// resolves, all derived state falls back to defaults so the page chrome
// stays interactive.
let summary = $state<DashboardSummary | null>(null);
function findMostExpensiveMix(mixes: Mix[]) {
return [...mixes].sort((left, right) => (right.mix_cost_per_kg ?? 0) - (left.mix_cost_per_kg ?? 0))[0];
}
$effect(() => {
let cancelled = false;
Promise.resolve(data.summary).then((value) => {
if (!cancelled) summary = value;
});
return () => {
cancelled = true;
};
});
function findLatestMaterial(materials: RawMaterial[]) {
return [...materials].sort((left, right) => {
const leftDate = left.current_price?.effective_date ?? '';
const rightDate = right.current_price?.effective_date ?? '';
return rightDate.localeCompare(leftDate);
})[0];
}
function buildSegments() {
function buildSegments(current: DashboardSummary | null) {
return [
{ label: 'Materials', value: data.rawMaterials.length, color: '#2c9b5f' },
{ label: 'Mixes', value: data.mixes.length, color: '#d7802a' },
{ label: 'Products', value: data.productCosts.length, color: '#286ea7' }
{ label: 'Materials', value: current?.raw_materials?.count ?? 0, color: '#2c9b5f' },
{ label: 'Mixes', value: current?.mixes?.count ?? 0, color: '#d7802a' },
{ label: 'Products', value: current?.products?.count ?? 0, color: '#286ea7' }
];
}
@@ -176,11 +184,12 @@
});
}
function buildTrendSeries() {
function buildTrendSeries(current: DashboardSummary | null) {
const trends = current?.trend_seeds;
const seeds = [
...data.rawMaterials.map((material: RawMaterial) => (material.current_price?.cost_per_kg ?? 0) * 780),
...data.mixes.map((mix: Mix) => (mix.mix_cost_per_kg ?? 0) * 640),
...data.productCosts.map((product: ProductCostBreakdown) => product.finished_product_delivered * 24)
...(trends?.raw_material_cost_per_kg ?? []).map((value) => value * 780),
...(trends?.mix_cost_per_kg ?? []).map((value) => value * 640),
...(trends?.product_finished_delivered ?? []).map((value) => value * 24)
].filter((value) => value > 0);
const source = seeds.length ? seeds : [320, 360, 420];
@@ -237,23 +246,23 @@
};
}
function buildFocusCards(): WorkspaceFocus[] {
const featuredMaterial = findLatestMaterial(data.rawMaterials);
const featuredMix = findMostExpensiveMix(data.mixes);
const featuredProduct = findHighestProduct(data.productCosts);
function buildFocusCards(current: DashboardSummary | null): WorkspaceFocus[] {
const featuredMaterial = current?.raw_materials?.latest ?? null;
const featuredMix = current?.mixes?.top ?? null;
const featuredProduct = current?.products?.top ?? null;
return [
{
code: 'RM',
label: featuredMaterial?.name ?? 'Raw material',
detail: `Updated ${formatDate(featuredMaterial?.current_price?.effective_date)}`,
value: currency(featuredMaterial?.current_price?.market_value),
detail: `Updated ${formatDate(featuredMaterial?.effective_date)}`,
value: currency(featuredMaterial?.market_value),
tone: 'positive'
},
{
code: 'MX',
label: featuredMix?.name ?? 'Mix worksheet',
detail: `${featuredMix?.ingredients.length ?? 0} ingredients loaded`,
detail: `${featuredMix?.ingredients_count ?? 0} ingredients loaded`,
value: `${currency(featuredMix?.mix_cost_per_kg, 4)} / kg`,
tone: featuredMix?.warnings.length ? 'warning' : 'neutral'
},
@@ -267,35 +276,24 @@
];
}
const featuredProduct = $derived(findHighestProduct(data.productCosts));
const featuredMix = $derived(findMostExpensiveMix(data.mixes));
const featuredMaterial = $derived(findLatestMaterial(data.rawMaterials));
const productionSegments = $derived(buildSegments());
const featuredProduct = $derived(summary?.products?.top ?? null);
const featuredMix = $derived(summary?.mixes?.top ?? null);
const featuredMaterial = $derived(summary?.raw_materials?.latest ?? null);
const productionSegments = $derived(buildSegments(summary));
const gaugeBars = $derived(buildGaugeBars(productionSegments));
const totalTracked = $derived(
productionSegments.reduce((sum: number, segment: Segment) => sum + segment.value, 0)
);
const totalMarketValue = $derived(
data.rawMaterials.reduce(
(sum: number, material: RawMaterial) => sum + (material.current_price?.market_value ?? 0),
0
)
);
const averageMixCost = $derived(
data.mixes.length
? data.mixes.reduce((sum: number, mix: Mix) => sum + (mix.mix_cost_per_kg ?? 0), 0) / data.mixes.length
: 0
);
const trendSeries = $derived(buildTrendSeries());
const totalMarketValue = $derived(summary?.raw_materials?.total_market_value ?? 0);
const averageMixCost = $derived(summary?.mixes?.average_cost_per_kg ?? 0);
const trendSeries = $derived(buildTrendSeries(summary));
const trendLine = $derived(linePath(trendSeries));
const trendArea = $derived(areaPath(trendSeries));
const trendFocus = $derived(focusMarker(trendSeries));
const topProducts = $derived(
[...data.productCosts]
.sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)
.slice(0, 4)
);
const focusCards = $derived(buildFocusCards());
const topProducts = $derived(summary?.products?.top_products ?? []);
const focusCards = $derived(buildFocusCards(summary));
const loading = $derived(summary === null);
const greeting = $derived(timeOfDay());
</script>
{#if !$sessionHydrated}
@@ -403,40 +401,41 @@
</section>
{:else}
<section class="dashboard-intro">
<div>
<div class="hero-label-row">
<p class="eyebrow">Client Workspace</p>
<span class="release-pill">{releaseStage}</span>
</div>
<h2>{greetingForAst()}, {firstName($clientSession?.name)}</h2>
<p>Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.</p>
<div class="greeting-row">
{#snippet greetIcon()}
{@const Icon = greeting.icon}
<span class={`greeting-icon ${greeting.tone}`} aria-hidden="true">
<Icon size={44} strokeWidth={1.6} />
</span>
{/snippet}
{@render greetIcon()}
<h2>{greeting.label}, {firstName($clientSession?.name)}</h2>
</div>
<div class="intro-actions">
<button class="secondary-button" type="button">Apr, 2026</button>
<a class="primary-button" href="/products">Review Delivered Pricing</a>
</div>
</section>
<section class="workspace-banner">
<div>
<p class="eyebrow">Account</p>
<h3>Hunter Premium Produce</h3>
<p>Lean 101 powers the client workspace while operator-only administration now lives in the separate `/admin` area.</p>
</div>
<div class="focus-grid">
{#each focusCards as card}
<article class={`focus-card ${card.tone}`}>
<span class="focus-code">{card.code}</span>
<div>
<section class="focus-row">
{#each focusCards as card, i}
<article class={`focus-card ${card.tone}`}>
<span class="focus-code">{card.code}</span>
<div>
{#if loading}
<Skeleton width="9rem" height="0.95rem" />
<Skeleton width="6rem" height="0.7rem" />
{:else}
<strong>{card.label}</strong>
<span>{card.detail}</span>
</div>
{/if}
</div>
{#if loading}
<Skeleton width="4rem" height="1rem" />
{:else}
<em>{card.value}</em>
</article>
{/each}
</div>
{/if}
</article>
{/each}
</section>
<section class="dashboard-grid">
@@ -451,13 +450,21 @@
<div class="market-layout">
<div>
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3>
<p>{formatDate(featuredMaterial?.current_price?.effective_date)}</p>
<div class="hero-value">{currency(featuredMaterial?.current_price?.market_value)}</div>
<p class="support-text">
{currency(featuredMaterial?.current_price?.cost_per_kg, 4)} / kg
<span>Current blend for Hunter Premium Produce</span>
</p>
{#if loading}
<Skeleton width="14rem" height="1.5rem" />
<div style="height:0.5rem"></div>
<Skeleton width="8rem" height="0.85rem" />
<div class="hero-value"><Skeleton width="9rem" height="2.6rem" /></div>
<Skeleton width="11rem" height="0.85rem" />
{:else}
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3>
<p>{formatDate(featuredMaterial?.effective_date)}</p>
<div class="hero-value">{currency(featuredMaterial?.market_value)}</div>
<p class="support-text">
{currency(featuredMaterial?.cost_per_kg, 4)} / kg
<span>Current blend for Hunter Premium Produce</span>
</p>
{/if}
</div>
<div class="field-emblem" aria-hidden="true">
@@ -512,7 +519,11 @@
<span>Total Input Spend</span>
<span class="metric-icon"></span>
</div>
<strong>{currency(totalMarketValue)}</strong>
{#if loading}
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
{:else}
<strong>{currency(totalMarketValue)}</strong>
{/if}
<p>Across all tracked raw materials</p>
</article>
@@ -521,7 +532,11 @@
<span>Average Mix Cost</span>
<span class="metric-icon"></span>
</div>
<strong>{currency(averageMixCost, 4)}</strong>
{#if loading}
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
{:else}
<strong>{currency(averageMixCost, 4)}</strong>
{/if}
<p>Per kg across the current mix set</p>
</article>
@@ -530,8 +545,13 @@
<span>Top Delivered Output</span>
<span class="metric-icon"></span>
</div>
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong>
<p>{featuredProduct?.product_name ?? 'No products loaded'}</p>
{#if loading}
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
<p><Skeleton width="9rem" height="0.85rem" /></p>
{:else}
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong>
<p>{featuredProduct?.product_name ?? 'No products loaded'}</p>
{/if}
</article>
</div>
</section>
@@ -599,7 +619,7 @@
<div class="preview-facts">
<article>
<span>Ingredients</span>
<strong>{featuredMix?.ingredients.length ?? 0}</strong>
<strong>{featuredMix?.ingredients_count ?? 0}</strong>
</article>
<article>
@@ -996,12 +1016,30 @@
.dashboard-intro,
.workspace-banner,
.focus-row,
.dashboard-grid,
.analysis-grid,
.detail-grid {
margin-bottom: 1.25rem;
}
.focus-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
@media (max-width: 1120px) {
.focus-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.focus-row { grid-template-columns: 1fr; }
}
.dashboard-intro h2 {
font-size: clamp(1.4rem, 2.4vw, 1.85rem);
}
.dashboard-intro,
.card-toolbar,
.metric-head,
@@ -1020,15 +1058,28 @@
align-items: flex-end;
}
.dashboard-intro h2,
.workspace-banner h3 {
.dashboard-intro h2 {
margin: 0.3rem 0 0.35rem;
font-size: clamp(1.8rem, 3vw, 2.35rem);
font-weight: 700;
}
.dashboard-intro p:last-child,
.workspace-banner p:last-child,
.greeting-row {
display: flex;
align-items: center;
gap: 1rem;
}
.greeting-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
color: var(--text);
flex-shrink: 0;
}
.card-toolbar p,
.metric-card p,
.preview-header p,
+30 -34
View File
@@ -1,42 +1,38 @@
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
import { api } from '$lib/api';
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
import type { DashboardSummary } from '$lib/types';
export async function load({ fetch }) {
const EMPTY_SUMMARY: DashboardSummary = {
raw_materials: null,
mixes: null,
products: null,
trend_seeds: { raw_material_cost_per_kg: [], mix_cost_per_kg: [], product_finished_delivered: [] }
};
// Streaming load: the route shell paints immediately and the dashboard fills
// in once `summary` resolves. This replaces the previous load that awaited
// five separate full collections (raw materials, mixes, all product cost
// breakdowns, scenarios, data-quality) before SvelteKit would render anything.
export function load({ fetch }) {
if (!hasStoredClientSession()) {
return {
rawMaterials: [],
mixes: [],
productCosts: [],
scenarios: [],
dataQuality: []
};
return { summary: Promise.resolve(EMPTY_SUMMARY) };
}
// Skip data fetching for sessions that lack any dashboard-eligible module
// — the backend would just return nulls anyway.
const session = getStoredClientSession();
try {
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'scenarios') ? api.scenarios(fetch) : Promise.resolve([]),
hasModuleAccess(session, 'dashboard') ? api.dataQuality(fetch) : Promise.resolve([])
]);
return {
rawMaterials,
mixes,
productCosts,
scenarios,
dataQuality
};
} catch {
return {
rawMaterials: [],
mixes: [],
productCosts: [],
scenarios: [],
dataQuality: []
};
const permissions = session?.module_permissions ?? {};
const hasAnyDashboardData =
session?.role === 'admin' ||
permissions.dashboard ||
permissions.raw_materials ||
permissions.mix_master ||
permissions.products;
if (!hasAnyDashboardData) {
return { summary: Promise.resolve(EMPTY_SUMMARY) };
}
return {
summary: api.dashboardSummary(fetch).catch(() => EMPTY_SUMMARY)
};
}
@@ -151,14 +151,6 @@
const previewJson = $derived(JSON.stringify(exportPreview, null, 2));
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Client Amend Area</p>
<h2>Control new users, existing users, and every feature flag in one operational workspace.</h2>
<p>The preview shows the live Power BI export payload after each amendment so the admin surface and reporting output stay aligned.</p>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Clients</span>
@@ -18,16 +18,11 @@
}
</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}
{#if canEdit}
<section class="page-actions">
<a class="primary-button" href="/mix-calculator/new">New mix session</a>
{/if}
</section>
</section>
{/if}
<section class="metric-row">
<article class="metric-card">
@@ -123,27 +118,17 @@
text-transform: uppercase;
}
.page-intro,
.page-actions,
.metric-row,
.table-card {
margin-bottom: 1.25rem;
}
.page-intro {
.page-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
justify-content: flex-end;
}
.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 {
@@ -283,7 +268,6 @@
}
@media (max-width: 760px) {
.page-intro,
.table-toolbar {
flex-direction: column;
align-items: flex-start;
+8 -12
View File
@@ -104,16 +104,8 @@
});
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Mix Master</p>
<h2>Saved mixes in a clean table view.</h2>
<p>Use the table to browse mixes, then open a dedicated worksheet page to edit or create a formulation.</p>
</div>
<div class="intro-actions">
<a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
</div>
<section class="page-actions">
<a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
</section>
<section class="metric-row">
@@ -221,13 +213,17 @@
text-transform: uppercase;
}
.page-intro,
.page-actions,
.metric-row,
.table-card {
margin-bottom: 1.12rem;
}
.page-intro,
.page-actions {
display: flex;
justify-content: flex-end;
}
.metric-card,
.table-card {
background: var(--panel);
@@ -46,14 +46,6 @@
);
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Output Pricing</p>
<h2>Delivered product pricing</h2>
<p>Each row carries the product, mix source, price outputs, and a quick health state in one compact layout.</p>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Products</span>
@@ -152,19 +152,6 @@
<a href="/">Return to sign-in</a>
</section>
{:else}
<section class="page-intro">
<div>
<p class="eyebrow">Input Cost Control</p>
<h2>Maintain raw materials with a cleaner operational workflow.</h2>
<p>Update source pricing, track downstream exposure, and keep the costing engine current from one workspace.</p>
</div>
<div class="intro-chip">
<span>{$clientSession.email}</span>
<strong>{activeMaterials.length} active materials</strong>
</div>
</section>
{#if successMessage}
<p class="feedback success">{successMessage}</p>
{/if}
@@ -11,14 +11,6 @@
const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length);
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Scenario Sandbox</p>
<h2>Simulation workspaces with a cleaner review and comparison layer.</h2>
<p>Scenarios now read like structured operating plans instead of raw debug output.</p>
</div>
</section>
<section class="metric-row">
<article class="metric-card">
<span>Total Scenarios</span>
@@ -4,14 +4,6 @@
const currentYear = new Date().getFullYear();
</script>
<section class="page-intro">
<div>
<p class="eyebrow">Workspace Settings</p>
<h2>Account and workspace preferences.</h2>
<p>Review your current session, navigation setup, and the client workspace details shown across the app.</p>
</div>
</section>
<section class="settings-grid">
<article class="surface-card">
<p class="eyebrow">Session</p>