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 import select
from sqlalchemy.orm import Session, selectinload 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.core.security import verify_token
from app.db.session import get_db from app.db.session import get_db
from app.models.access import Role, User
from app.models.client_access import ClientFeatureAccess, ClientUser from app.models.client_access import ClientFeatureAccess, ClientUser
from app.services.client_access_service import has_access_level, module_access_map 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 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: if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
payload = verify_token(credentials.credentials) 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( return AuthSession(
role=str(payload.get("role", "")), role=str(payload.get("role", "")),
email=str(payload.get("email", "")), 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: 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": if session.role != "client":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required")
if not session.tenant_id: 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), session: AuthSession = Depends(require_client_session),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> AuthSession: ) -> 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) user = load_current_client_user(db, session)
feature = db.scalar( feature = db.scalar(
select(ClientFeatureAccess).where( 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()): if int(payload.get("exp", 0)) < int(time.time()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication token has expired") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication token has expired")
return payload 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 from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from app.api.access import router as access_router
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.client_access import router as client_access_router from app.api.client_access import router as client_access_router
from app.api.dashboard import router as dashboard_router
from app.api.mix_calculator import router as mix_calculator_router from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router from app.api.powerbi import router as powerbi_router
@@ -72,7 +74,9 @@ app.add_middleware(
) )
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(access_router)
app.include_router(client_access_router) app.include_router(client_access_router)
app.include_router(dashboard_router)
app.include_router(raw_materials_router) app.include_router(raw_materials_router)
app.include_router(mixes_router) app.include_router(mixes_router)
app.include_router(mix_calculator_router) app.include_router(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.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
@@ -19,9 +20,13 @@ __all__ = [
"MixCalculatorSessionLine", "MixCalculatorSessionLine",
"MixIngredient", "MixIngredient",
"PackagingCostRule", "PackagingCostRule",
"Permission",
"ProcessCostRule", "ProcessCostRule",
"Product", "Product",
"RawMaterial", "RawMaterial",
"RawMaterialPriceVersion", "RawMaterialPriceVersion",
"Role",
"Scenario", "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.mix import Mix, MixIngredient
from app.models.product import Product from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.seed_access import seed_access
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
@@ -688,6 +689,7 @@ def seed_if_empty():
else: else:
logger.warning("Skipping costing workspace seed because workbook is missing at %s", WORKBOOK_PATH) logger.warning("Skipping costing workspace seed because workbook is missing at %s", WORKBOOK_PATH)
seed_client_access(db) seed_client_access(db)
seed_access(db)
db.commit() 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 datetime import date
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession from app.api.deps import AuthSession
from app.models.mix import Mix, MixIngredient 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: 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( products = db.scalars(
select(Product) select(Product)
.where(Product.tenant_id == tenant_id) .where(Product.tenant_id == tenant_id)
.options(selectinload(Product.mix).selectinload(Mix.ingredients)) .options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name) .order_by(Product.client_name, Product.name)
).all() ).all()
product_rows = []
clients = sorted({product.client_name for product in products}) clients = sorted({product.client_name for product in products})
for product in products: product_rows = [
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_id": product.id, "product_name": product.name,
"client_name": product.client_name, "mix_id": product.mix_id,
"product_name": product.name, "mix_name": product.mix.name if product.mix else "",
"mix_id": product.mix_id, "unit_of_measure": product.unit_of_measure,
"mix_name": product.mix.name if product.mix else "", "unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"unit_of_measure": product.unit_of_measure, "mix_total_kg": mix_totals.get(product.mix_id, 0.0),
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4), }
"mix_total_kg": mix_total_kg, for product in products
} ]
)
return {"clients": clients, "products": product_rows} 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", "name": "data-entry-app-frontend",
"version": "0.1.5", "version": "0.1.5",
"dependencies": {
"lucide-svelte": "^1.0.1"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.2.0", "@sveltejs/adapter-auto": "^3.2.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
@@ -55,7 +58,6 @@
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -66,7 +68,6 @@
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
@@ -77,7 +78,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -87,14 +87,12 @@
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
@@ -882,7 +880,6 @@
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"acorn": "^8.9.0" "acorn": "^8.9.0"
@@ -1020,7 +1017,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
@@ -1034,7 +1030,6 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
@@ -1154,7 +1149,6 @@
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@@ -1167,7 +1161,6 @@
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -1187,7 +1180,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -1207,7 +1199,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -1261,7 +1252,6 @@
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-errors": { "node_modules/es-errors": {
@@ -1285,14 +1275,12 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "2.2.5", "version": "2.2.5",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.4.15"
@@ -1420,7 +1408,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "^1.0.6" "@types/estree": "^1.0.6"
@@ -1701,14 +1688,21 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
@@ -1988,7 +1982,6 @@
"version": "5.55.5", "version": "5.55.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
@@ -2298,7 +2291,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
} }
} }
+3
View File
@@ -17,5 +17,8 @@
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^8.0.0", "vite": "^8.0.0",
"vitest": "^4.0.0" "vitest": "^4.0.0"
},
"dependencies": {
"lucide-svelte": "^1.0.1"
} }
} }
+95 -10
View File
@@ -14,6 +14,7 @@ import {
import type { import type {
ClientAccessAccount, ClientAccessAccount,
ClientAccessPowerBiExport, ClientAccessPowerBiExport,
DashboardSummary,
ClientUserCreateInput, ClientUserCreateInput,
ClientUserModulePermission, ClientUserModulePermission,
ClientUserUpdateInput, 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>( async function request<T>(
path: string, path: string,
options: RequestInit, options: RequestInit,
@@ -155,6 +212,12 @@ async function request<T>(
throw new Error(message); 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; return (await response.json()) as T;
} catch (error) { } catch (error) {
throw normalizeRequestError(error); throw normalizeRequestError(error);
@@ -162,13 +225,13 @@ async function request<T>(
} }
export const api = { export const api = {
rawMaterials: (fetcher?: ApiFetch) => fetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher), rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', mockRawMaterials, 'client', fetcher),
mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, '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), mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
mixCalculatorOptions: (fetcher?: ApiFetch) => mixCalculatorOptions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher), cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher),
mixCalculatorSessions: (fetcher?: ApiFetch) => mixCalculatorSessions: (fetcher?: ApiFetch) =>
fetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher), cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher),
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) => mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher), request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) => previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
@@ -186,19 +249,41 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}, 'client'), }, 'client'),
products: (fetcher?: ApiFetch) => fetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher), products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', mockProducts, 'client', fetcher),
productCosts: (fetcher?: ApiFetch) => productCosts: (fetcher?: ApiFetch) =>
fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher), cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts, 'client', fetcher),
scenarios: (fetcher?: ApiFetch) => fetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher), scenarios: (fetcher?: ApiFetch) => cachedFetchJson<Scenario[]>('/api/scenarios', mockScenarios, 'client', fetcher),
clientAccess: (fetcher?: ApiFetch) => fetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher), clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', mockClientAccess, 'manager', fetcher),
clientAccessExport: (fetcher?: ApiFetch) => clientAccessExport: (fetcher?: ApiFetch) =>
fetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher), cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', mockClientAccessExport, 'manager', fetcher),
dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', 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) => clientLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/client/login', { request<LoginResponse>('/api/auth/client/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }) 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) => adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', { request<LoginResponse>('/api/auth/admin/login', {
method: 'POST', method: 'POST',
+284 -248
View File
@@ -7,6 +7,24 @@
import { featureFlags } from '$lib/features'; import { featureFlags } from '$lib/features';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import packageInfo from '../../../package.json'; 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 = { type SearchItem = {
href: string; href: string;
@@ -19,29 +37,31 @@
href: string; href: string;
label: string; label: string;
shortLabel: string; shortLabel: string;
icon?: 'home'; icon: ComponentType;
moduleKey?: string; 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 = { const mixCalculatorItem: NavItem = {
href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new', href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new',
label: 'Mix Calculator', label: 'Mix Calculator',
shortLabel: 'MC', shortLabel: 'MC',
icon: Calculator,
moduleKey: 'mix_calculator' moduleKey: 'mix_calculator'
}; };
const workingDocumentItems: NavItem[] = [ const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' }, { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' }, { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
{ href: '/products', label: 'Products', shortLabel: 'PR', moduleKey: 'products' }, { href: '/products', label: 'Products', shortLabel: 'PR', icon: Boxes, moduleKey: 'products' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC', moduleKey: 'scenarios' } { 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 navigation = [dashboardItem, mixCalculatorItem, ...workingDocumentItems, accessControlItem];
const footerLinks = [ type FooterLink = { href: string; label: string; shortLabel: string; icon: ComponentType };
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' }, const footerLinks: FooterLink[] = [
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' } { href: '/products', label: 'Delivered Pricing', shortLabel: 'DP', icon: DollarSign },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV', icon: ClipboardList }
]; ];
const baseSearchItems: SearchItem[] = [ const baseSearchItems: SearchItem[] = [
@@ -140,8 +160,8 @@
...footerLinks, ...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage') ...(!$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( const primaryBottomNavigation = $derived(
[ [
...(visibleDashboardItem ? [visibleDashboardItem] : []), ...(visibleDashboardItem ? [visibleDashboardItem] : []),
@@ -162,25 +182,44 @@
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard'; return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
} }
function pageDescription(pathname: string) { type Crumb = { label: string; href?: string };
if (pathname.startsWith('/mix-calculator/')) {
return 'Review a saved mix calculation session and prepare a printable output'; // 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> = { if (pathname.startsWith('/mix-calculator')) {
'/': 'Hunter Premium Produce client workspace', const trail: Crumb[] = [root, { label: 'Mix Calculator', href: '/mix-calculator' }];
'/raw-materials': 'Review source input costs and downstream exposure', if (pathname === '/mix-calculator/new') trail.push({ label: 'New Session' });
'/mixes': 'Browse saved mix worksheets and costing outputs', else if (pathname.endsWith('/print')) trail.push({ label: 'Print' });
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce', else if (pathname !== '/mix-calculator') trail.push({ label: 'Session' });
'/mix-calculator': 'Create and review client-specific mix calculation sessions', return trail;
'/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'
};
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 = '') { function openPalette(query = '') {
@@ -255,10 +294,16 @@
restoredToken = token; restoredToken = token;
isRestoringSession = true; 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) => { .then((session) => {
restoredToken = session.token; // /api/access/me does not re-issue a token; preserve the existing one.
clientSession.set(session); const nextToken = session.token ?? token;
restoredToken = nextToken;
clientSession.set({ ...session, token: nextToken });
return invalidateAll(); return invalidateAll();
}) })
.catch(() => { .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(() => { $effect(() => {
const hydrated = $sessionHydrated; const hydrated = $sessionHydrated;
const session = $clientSession; const session = $clientSession;
const token = session?.token ?? null; const token = session?.token ?? null;
const shouldSeed = paletteOpen;
if (!hydrated || !session || !token) { if (!hydrated || !session || !token) {
seededSearchItems = []; seededSearchItems = [];
@@ -281,7 +330,7 @@
return; return;
} }
if (seededSearchToken === token) { if (!shouldSeed || seededSearchToken === token) {
return; return;
} }
@@ -398,90 +447,65 @@
</div> </div>
<div class="sidebar-body"> <div class="sidebar-body">
<p class="nav-section-label">Workspace</p>
<nav class="nav-list" aria-label="Client navigation"> <nav class="nav-list" aria-label="Client navigation">
{#if visibleDashboardItem} {#if visibleDashboardItem}
{@const Icon = visibleDashboardItem.icon}
<a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href}> <a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href}>
<span class="nav-icon"> <span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<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>{visibleDashboardItem.label}</span> <span>{visibleDashboardItem.label}</span>
</a> </a>
{/if} {/if}
{#if visibleMixCalculatorItem} {#if visibleMixCalculatorItem}
{@const Icon = visibleMixCalculatorItem.icon}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href}> <a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href}>
<span class="nav-icon"> <span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
</span>
<span>{visibleMixCalculatorItem.label}</span> <span>{visibleMixCalculatorItem.label}</span>
</a> </a>
{/if} {/if}
</nav> </nav>
<div class="nav-group" aria-label="Working documents" hidden={!visibleWorkingDocumentItems.length}> {#if visibleWorkingDocumentItems.length}
<button <p class="nav-section-label">Working Docs</p>
aria-controls="working-documents-nav" <nav class="nav-list" aria-label="Working document pages">
aria-expanded={workingDocumentsExpanded} {#each visibleWorkingDocumentItems as item}
class:active={workingDocumentsActive} {@const Icon = item.icon}
class="nav-group-toggle" <a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
type="button" <span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)} <span>{item.label}</span>
> </a>
<span class="nav-group-toggle-copy"> {/each}
<span class="nav-icon muted">WD</span> </nav>
<span>Working Docs</span> {/if}
</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>
<div class="sidebar-footer"> {#if visibleFooterLinks.length}
{#each visibleFooterLinks as item} <p class="nav-section-label">More</p>
<a href={item.href}> <nav class="nav-list" aria-label="Workspace shortcuts">
<span class="nav-icon muted">{item.shortLabel}</span> {#each visibleFooterLinks as item}
<span>{item.label}</span> {@const Icon = item.icon}
</a> <a href={item.href}>
{/each} <span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
</div> <span>{item.label}</span>
</a>
{/each}
</nav>
{/if}
<div class="sidebar-meta"> <div class="sidebar-meta">
<span class="sidebar-version-row"> {#if $clientSession}
<span>{appVersion}</span> <button class="sidebar-signout" type="button" onclick={() => clientSession.clear()}>
<span class="release-pill">{releaseStage}</span> <span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
</span> <span>Sign out</span>
<small>&copy; {currentYear} Hunter Premium Produce</small> </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>
</div> </div>
</aside> </aside>
@@ -491,8 +515,17 @@
<header class="topbar"> <header class="topbar">
<div class="topbar-start"> <div class="topbar-start">
<div class="topbar-copy"> <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> <h1>{pageTitle(page.url.pathname)}</h1>
<p>{pageDescription(page.url.pathname)}</p>
</div> </div>
</div> </div>
@@ -599,42 +632,15 @@
{#if showBottomNav} {#if showBottomNav}
<nav class="bottom-nav" aria-label="Tablet navigation"> <nav class="bottom-nav" aria-label="Tablet navigation">
{#each primaryBottomNavigation as item} {#each primaryBottomNavigation as item}
{@const Icon = item.icon}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}> <a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="bottom-nav-icon"> <span class="bottom-nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
{#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>{item.label}</span> <span>{item.label}</span>
</a> </a>
{/each} {/each}
<button aria-expanded={navOpen} class:active={navOpen} type="button" onclick={() => (navOpen = !navOpen)}> <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> <span>More</span>
</button> </button>
</nav> </nav>
@@ -668,97 +674,58 @@
<div class="drawer-grid"> <div class="drawer-grid">
<nav class="drawer-section" aria-label="All workspace pages"> <nav class="drawer-section" aria-label="All workspace pages">
{#if visibleDashboardItem} {#if visibleDashboardItem}
{@const Icon = visibleDashboardItem.icon}
<a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href} onclick={() => (navOpen = false)}> <a class:active={matchesRoute(visibleDashboardItem.href, page.url.pathname)} href={visibleDashboardItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon"> <span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<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>{visibleDashboardItem.label}</span> <span>{visibleDashboardItem.label}</span>
</a> </a>
{/if} {/if}
{#if visibleMixCalculatorItem} {#if visibleMixCalculatorItem}
{@const Icon = visibleMixCalculatorItem.icon}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href} onclick={() => (navOpen = false)}> <a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon"> <span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
</span>
<span>{visibleMixCalculatorItem.label}</span> <span>{visibleMixCalculatorItem.label}</span>
</a> </a>
{/if} {/if}
<div class="drawer-group" hidden={!visibleWorkingDocumentItems.length}> {#if visibleWorkingDocumentItems.length}
<button <div class="drawer-sublist" id="drawer-working-documents-nav">
aria-controls="drawer-working-documents-nav" {#each visibleWorkingDocumentItems as item}
aria-expanded={workingDocumentsExpanded} {@const Icon = item.icon}
class:active={workingDocumentsActive} <a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
class="nav-group-toggle drawer-group-toggle" <span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
type="button" <span>{item.label}</span>
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)} </a>
> {/each}
<span class="nav-group-toggle-copy"> </div>
<span class="nav-icon muted">WD</span> {/if}
<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>
</nav> </nav>
<div class="drawer-section drawer-actions"> <div class="drawer-section drawer-actions">
<a href="/mixes/new" onclick={() => (navOpen = false)}> <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> <span>Create mix worksheet</span>
</a> </a>
<a href="/mix-calculator/new" onclick={() => (navOpen = false)}> <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> <span>Create mix session</span>
</a> </a>
<button type="button" onclick={openSettings}> <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> <span>Change settings</span>
</button> </button>
<a href="/products" onclick={() => (navOpen = false)}> <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> <span>Review delivered pricing</span>
</a> </a>
<button type="button" onclick={() => openPalette('')}> <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> <span>Search the workspace</span>
</button> </button>
{#if $clientSession} {#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}> <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> <span>Sign out</span>
</button> </button>
{/if} {/if}
@@ -871,7 +838,7 @@
.app-shell { .app-shell {
display: grid; display: grid;
grid-template-columns: 244px minmax(0, 1fr); grid-template-columns: 252px minmax(0, 1fr);
min-height: 100vh; min-height: 100vh;
} }
@@ -891,14 +858,15 @@
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.1rem; gap: 0.4rem;
padding: 0.9rem; padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel); background: var(--panel);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
overflow: hidden; overflow-y: auto;
scrollbar-width: thin;
} }
.sidebar-body { .sidebar-body {
@@ -906,7 +874,20 @@
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; 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 { .brand-row {
@@ -914,6 +895,8 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.68rem; gap: 0.68rem;
padding: 0 0.25rem 0.4rem;
border-bottom: 1px solid var(--line);
} }
.brand { .brand {
@@ -926,11 +909,21 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
color: #fff; color: #6d7d74;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%); background: transparent;
border-radius: 0.55rem;
width: 1.6rem;
height: 1.6rem;
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em; 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 { .nav-icon-mask {
@@ -1047,39 +1040,45 @@
font-size: 0.76rem; font-size: 0.76rem;
} }
.nav-list, .nav-list {
.nav-sublist,
.sidebar-footer {
display: grid; display: grid;
gap: 0.3rem; gap: 0.12rem;
} }
.nav-list a, .nav-list a {
.nav-sublist a, position: relative;
.sidebar-footer a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.68rem; gap: 0.7rem;
padding: 0.72rem 0.68rem; padding: 0.6rem 0.6rem;
border-radius: 0.82rem; border-radius: 0.7rem;
color: #304038; color: #3a4a41;
transition: background-color 160ms ease; font-size: 0.93rem;
transition: background-color 140ms ease, color 140ms ease;
} }
.nav-list a:hover, .nav-list a:hover {
.nav-sublist a:hover, background: rgba(234, 248, 239, 0.55);
.sidebar-footer a:hover, color: var(--green-deep);
.nav-list a.active, }
.nav-sublist a.active {
.nav-list a.active {
background: var(--green-soft); background: var(--green-soft);
}
.nav-list a.active,
.nav-sublist a.active {
color: var(--green-deep); color: var(--green-deep);
font-weight: 600; 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 { .nav-group {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;
@@ -1138,45 +1137,62 @@
position: relative; position: relative;
} }
.nav-icon {
width: 1.56rem;
height: 1.56rem;
border-radius: 0.56rem;
}
.nav-icon svg,
.bottom-nav-icon svg { .bottom-nav-icon svg {
width: 0.9rem; width: 0.9rem;
height: 0.9rem; height: 0.9rem;
} }
/* `.nav-icon.muted` is kept for the bottom-nav (still uses letter labels). */
.nav-icon.muted { .nav-icon.muted {
color: #fff;
background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%); background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%);
} }
.sidebar-footer {
margin-top: auto;
padding-top: 0.6rem;
flex-shrink: 0;
}
.sidebar-meta { .sidebar-meta {
margin-top: auto;
display: grid; display: grid;
gap: 0.2rem; gap: 0.55rem;
padding: 0.85rem 0.3rem 0; padding-top: 0.85rem;
color: var(--muted);
font-size: 0.78rem;
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-meta small { .sidebar-signout {
font-size: 0.74rem; 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; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.45rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -1218,20 +1234,40 @@
gap: 0.82rem; gap: 0.82rem;
} }
.topbar-copy h1,
.topbar-copy p {
margin: 0;
}
.topbar-copy h1 { .topbar-copy h1 {
font-size: 1.62rem; margin: 0.18rem 0 0;
font-size: 1.5rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em;
} }
.topbar-copy p { .breadcrumbs {
margin-top: 0.22rem; display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
color: var(--muted); 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 { .topbar-middle {
@@ -220,24 +220,16 @@
{/if} {/if}
</section> </section>
{:else} {:else}
<section class="page-intro"> {#if featureFlags.mixCalculatorSessionHistory || initialSession}
<div> <section class="page-actions">
<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} {#if featureFlags.mixCalculatorSessionHistory}
<a class="secondary-button" href="/mix-calculator">Session history</a> <a class="secondary-button" href="/mix-calculator">Session history</a>
{/if} {/if}
{#if initialSession} {#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a> <a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
{/if} {/if}
</div> </section>
</section> {/if}
<section class="workspace-grid"> <section class="workspace-grid">
<article class="form-card"> <article class="form-card">
@@ -556,11 +548,18 @@
mask: var(--button-icon-url) center / contain no-repeat; mask: var(--button-icon-url) center / contain no-repeat;
} }
.page-intro, .page-actions,
.workspace-grid { .workspace-grid {
margin-bottom: 1.2rem; margin-bottom: 1.2rem;
} }
.page-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
.page-intro { .page-intro {
display: flex; display: flex;
align-items: flex-start; 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; user_id?: number | null;
client_account_id?: number | null; client_account_id?: number | null;
module_permissions?: Record<string, string>; 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> = { const ACCESS_LEVEL_ORDER: Record<string, number> = {
@@ -63,6 +67,9 @@ function createSessionStore(storageKey: string) {
clear() { clear() {
if (browser) { if (browser) {
localStorage.removeItem(storageKey); 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); store.set(null);
} }
@@ -102,6 +109,23 @@ export function hasModuleAccess(
return (ACCESS_LEVEL_ORDER[currentLevel] ?? 0) >= ACCESS_LEVEL_ORDER[minimumLevel]; 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) => { export const sessionHydrated = readable(false, (set) => {
if (!browser) { if (!browser) {
return undefined; return undefined;
+37
View File
@@ -298,6 +298,38 @@ export type ClientAccessPowerBiExport = {
clients: ClientAccessAccount[]; 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 = { export type LoginResponse = {
name: string; name: string;
email: string; email: string;
@@ -308,6 +340,11 @@ export type LoginResponse = {
user_id?: number | null; user_id?: number | null;
client_account_id?: number | null; client_account_id?: number | null;
module_permissions?: Record<string, string>; 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 = { export type RawMaterialCreateInput = {
+147 -96
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api } from '$lib/api';
import { clientSession, sessionHydrated } from '$lib/session'; 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 packageInfo from '../../package.json';
import { Sunrise, Sun, Sunset, Moon } from 'lucide-svelte';
type Segment = { type Segment = {
label: string; label: string;
@@ -44,7 +46,10 @@
isLoggingIn = true; isLoggingIn = true;
try { 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); clientSession.set(session);
} catch (error) { } catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in'; loginError = error instanceof Error ? error.message : 'Unable to sign in';
@@ -99,7 +104,8 @@
}).format(new Date(value)); }).format(new Date(value));
} }
function greetingForAst() { // Australian Eastern time-of-day → greeting + matching Lucide icon.
function timeOfDay() {
const astHour = Number( const astHour = Number(
new Intl.DateTimeFormat('en-AU', { new Intl.DateTimeFormat('en-AU', {
hour: 'numeric', hour: 'numeric',
@@ -108,34 +114,36 @@
}).format(new Date()) }).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) { function firstName(name: string | null | undefined) {
return name?.trim().split(/\s+/)[0] ?? 'there'; return name?.trim().split(/\s+/)[0] ?? 'there';
} }
function findHighestProduct(products: ProductCostBreakdown[]) { // The dashboard summary streams in after the route shell paints. Until it
return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0]; // resolves, all derived state falls back to defaults so the page chrome
} // stays interactive.
let summary = $state<DashboardSummary | null>(null);
function findMostExpensiveMix(mixes: Mix[]) { $effect(() => {
return [...mixes].sort((left, right) => (right.mix_cost_per_kg ?? 0) - (left.mix_cost_per_kg ?? 0))[0]; let cancelled = false;
} Promise.resolve(data.summary).then((value) => {
if (!cancelled) summary = value;
});
return () => {
cancelled = true;
};
});
function findLatestMaterial(materials: RawMaterial[]) { function buildSegments(current: DashboardSummary | null) {
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() {
return [ return [
{ label: 'Materials', value: data.rawMaterials.length, color: '#2c9b5f' }, { label: 'Materials', value: current?.raw_materials?.count ?? 0, color: '#2c9b5f' },
{ label: 'Mixes', value: data.mixes.length, color: '#d7802a' }, { label: 'Mixes', value: current?.mixes?.count ?? 0, color: '#d7802a' },
{ label: 'Products', value: data.productCosts.length, color: '#286ea7' } { 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 = [ const seeds = [
...data.rawMaterials.map((material: RawMaterial) => (material.current_price?.cost_per_kg ?? 0) * 780), ...(trends?.raw_material_cost_per_kg ?? []).map((value) => value * 780),
...data.mixes.map((mix: Mix) => (mix.mix_cost_per_kg ?? 0) * 640), ...(trends?.mix_cost_per_kg ?? []).map((value) => value * 640),
...data.productCosts.map((product: ProductCostBreakdown) => product.finished_product_delivered * 24) ...(trends?.product_finished_delivered ?? []).map((value) => value * 24)
].filter((value) => value > 0); ].filter((value) => value > 0);
const source = seeds.length ? seeds : [320, 360, 420]; const source = seeds.length ? seeds : [320, 360, 420];
@@ -237,23 +246,23 @@
}; };
} }
function buildFocusCards(): WorkspaceFocus[] { function buildFocusCards(current: DashboardSummary | null): WorkspaceFocus[] {
const featuredMaterial = findLatestMaterial(data.rawMaterials); const featuredMaterial = current?.raw_materials?.latest ?? null;
const featuredMix = findMostExpensiveMix(data.mixes); const featuredMix = current?.mixes?.top ?? null;
const featuredProduct = findHighestProduct(data.productCosts); const featuredProduct = current?.products?.top ?? null;
return [ return [
{ {
code: 'RM', code: 'RM',
label: featuredMaterial?.name ?? 'Raw material', label: featuredMaterial?.name ?? 'Raw material',
detail: `Updated ${formatDate(featuredMaterial?.current_price?.effective_date)}`, detail: `Updated ${formatDate(featuredMaterial?.effective_date)}`,
value: currency(featuredMaterial?.current_price?.market_value), value: currency(featuredMaterial?.market_value),
tone: 'positive' tone: 'positive'
}, },
{ {
code: 'MX', code: 'MX',
label: featuredMix?.name ?? 'Mix worksheet', 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`, value: `${currency(featuredMix?.mix_cost_per_kg, 4)} / kg`,
tone: featuredMix?.warnings.length ? 'warning' : 'neutral' tone: featuredMix?.warnings.length ? 'warning' : 'neutral'
}, },
@@ -267,35 +276,24 @@
]; ];
} }
const featuredProduct = $derived(findHighestProduct(data.productCosts)); const featuredProduct = $derived(summary?.products?.top ?? null);
const featuredMix = $derived(findMostExpensiveMix(data.mixes)); const featuredMix = $derived(summary?.mixes?.top ?? null);
const featuredMaterial = $derived(findLatestMaterial(data.rawMaterials)); const featuredMaterial = $derived(summary?.raw_materials?.latest ?? null);
const productionSegments = $derived(buildSegments()); const productionSegments = $derived(buildSegments(summary));
const gaugeBars = $derived(buildGaugeBars(productionSegments)); const gaugeBars = $derived(buildGaugeBars(productionSegments));
const totalTracked = $derived( const totalTracked = $derived(
productionSegments.reduce((sum: number, segment: Segment) => sum + segment.value, 0) productionSegments.reduce((sum: number, segment: Segment) => sum + segment.value, 0)
); );
const totalMarketValue = $derived( const totalMarketValue = $derived(summary?.raw_materials?.total_market_value ?? 0);
data.rawMaterials.reduce( const averageMixCost = $derived(summary?.mixes?.average_cost_per_kg ?? 0);
(sum: number, material: RawMaterial) => sum + (material.current_price?.market_value ?? 0), const trendSeries = $derived(buildTrendSeries(summary));
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 trendLine = $derived(linePath(trendSeries)); const trendLine = $derived(linePath(trendSeries));
const trendArea = $derived(areaPath(trendSeries)); const trendArea = $derived(areaPath(trendSeries));
const trendFocus = $derived(focusMarker(trendSeries)); const trendFocus = $derived(focusMarker(trendSeries));
const topProducts = $derived( const topProducts = $derived(summary?.products?.top_products ?? []);
[...data.productCosts] const focusCards = $derived(buildFocusCards(summary));
.sort((left, right) => right.finished_product_delivered - left.finished_product_delivered) const loading = $derived(summary === null);
.slice(0, 4) const greeting = $derived(timeOfDay());
);
const focusCards = $derived(buildFocusCards());
</script> </script>
{#if !$sessionHydrated} {#if !$sessionHydrated}
@@ -403,40 +401,41 @@
</section> </section>
{:else} {:else}
<section class="dashboard-intro"> <section class="dashboard-intro">
<div> <div class="greeting-row">
<div class="hero-label-row"> {#snippet greetIcon()}
<p class="eyebrow">Client Workspace</p> {@const Icon = greeting.icon}
<span class="release-pill">{releaseStage}</span> <span class={`greeting-icon ${greeting.tone}`} aria-hidden="true">
</div> <Icon size={44} strokeWidth={1.6} />
<h2>{greetingForAst()}, {firstName($clientSession?.name)}</h2> </span>
<p>Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.</p> {/snippet}
{@render greetIcon()}
<h2>{greeting.label}, {firstName($clientSession?.name)}</h2>
</div> </div>
<div class="intro-actions"> <div class="intro-actions">
<button class="secondary-button" type="button">Apr, 2026</button>
<a class="primary-button" href="/products">Review Delivered Pricing</a> <a class="primary-button" href="/products">Review Delivered Pricing</a>
</div> </div>
</section> </section>
<section class="workspace-banner"> <section class="focus-row">
<div> {#each focusCards as card, i}
<p class="eyebrow">Account</p> <article class={`focus-card ${card.tone}`}>
<h3>Hunter Premium Produce</h3> <span class="focus-code">{card.code}</span>
<p>Lean 101 powers the client workspace while operator-only administration now lives in the separate `/admin` area.</p> <div>
</div> {#if loading}
<Skeleton width="9rem" height="0.95rem" />
<div class="focus-grid"> <Skeleton width="6rem" height="0.7rem" />
{#each focusCards as card} {:else}
<article class={`focus-card ${card.tone}`}>
<span class="focus-code">{card.code}</span>
<div>
<strong>{card.label}</strong> <strong>{card.label}</strong>
<span>{card.detail}</span> <span>{card.detail}</span>
</div> {/if}
</div>
{#if loading}
<Skeleton width="4rem" height="1rem" />
{:else}
<em>{card.value}</em> <em>{card.value}</em>
</article> {/if}
{/each} </article>
</div> {/each}
</section> </section>
<section class="dashboard-grid"> <section class="dashboard-grid">
@@ -451,13 +450,21 @@
<div class="market-layout"> <div class="market-layout">
<div> <div>
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3> {#if loading}
<p>{formatDate(featuredMaterial?.current_price?.effective_date)}</p> <Skeleton width="14rem" height="1.5rem" />
<div class="hero-value">{currency(featuredMaterial?.current_price?.market_value)}</div> <div style="height:0.5rem"></div>
<p class="support-text"> <Skeleton width="8rem" height="0.85rem" />
{currency(featuredMaterial?.current_price?.cost_per_kg, 4)} / kg <div class="hero-value"><Skeleton width="9rem" height="2.6rem" /></div>
<span>Current blend for Hunter Premium Produce</span> <Skeleton width="11rem" height="0.85rem" />
</p> {: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>
<div class="field-emblem" aria-hidden="true"> <div class="field-emblem" aria-hidden="true">
@@ -512,7 +519,11 @@
<span>Total Input Spend</span> <span>Total Input Spend</span>
<span class="metric-icon"></span> <span class="metric-icon"></span>
</div> </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> <p>Across all tracked raw materials</p>
</article> </article>
@@ -521,7 +532,11 @@
<span>Average Mix Cost</span> <span>Average Mix Cost</span>
<span class="metric-icon"></span> <span class="metric-icon"></span>
</div> </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> <p>Per kg across the current mix set</p>
</article> </article>
@@ -530,8 +545,13 @@
<span>Top Delivered Output</span> <span>Top Delivered Output</span>
<span class="metric-icon"></span> <span class="metric-icon"></span>
</div> </div>
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong> {#if loading}
<p>{featuredProduct?.product_name ?? 'No products loaded'}</p> <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> </article>
</div> </div>
</section> </section>
@@ -599,7 +619,7 @@
<div class="preview-facts"> <div class="preview-facts">
<article> <article>
<span>Ingredients</span> <span>Ingredients</span>
<strong>{featuredMix?.ingredients.length ?? 0}</strong> <strong>{featuredMix?.ingredients_count ?? 0}</strong>
</article> </article>
<article> <article>
@@ -996,12 +1016,30 @@
.dashboard-intro, .dashboard-intro,
.workspace-banner, .workspace-banner,
.focus-row,
.dashboard-grid, .dashboard-grid,
.analysis-grid, .analysis-grid,
.detail-grid { .detail-grid {
margin-bottom: 1.25rem; 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, .dashboard-intro,
.card-toolbar, .card-toolbar,
.metric-head, .metric-head,
@@ -1020,15 +1058,28 @@
align-items: flex-end; align-items: flex-end;
} }
.dashboard-intro h2, .dashboard-intro h2 {
.workspace-banner h3 {
margin: 0.3rem 0 0.35rem; margin: 0.3rem 0 0.35rem;
font-size: clamp(1.8rem, 3vw, 2.35rem); font-size: clamp(1.8rem, 3vw, 2.35rem);
font-weight: 700; font-weight: 700;
} }
.dashboard-intro p:last-child, .greeting-row {
.workspace-banner p:last-child, 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, .card-toolbar p,
.metric-card p, .metric-card p,
.preview-header 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 { 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()) { if (!hasStoredClientSession()) {
return { return { summary: Promise.resolve(EMPTY_SUMMARY) };
rawMaterials: [],
mixes: [],
productCosts: [],
scenarios: [],
dataQuality: []
};
} }
// Skip data fetching for sessions that lack any dashboard-eligible module
// — the backend would just return nulls anyway.
const session = getStoredClientSession(); const session = getStoredClientSession();
const permissions = session?.module_permissions ?? {};
try { const hasAnyDashboardData =
const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([ session?.role === 'admin' ||
hasModuleAccess(session, 'raw_materials') ? api.rawMaterials(fetch) : Promise.resolve([]), permissions.dashboard ||
hasModuleAccess(session, 'mix_master') ? api.mixes(fetch) : Promise.resolve([]), permissions.raw_materials ||
hasModuleAccess(session, 'products') ? api.productCosts(fetch) : Promise.resolve([]), permissions.mix_master ||
hasModuleAccess(session, 'scenarios') ? api.scenarios(fetch) : Promise.resolve([]), permissions.products;
hasModuleAccess(session, 'dashboard') ? api.dataQuality(fetch) : Promise.resolve([]) if (!hasAnyDashboardData) {
]); return { summary: Promise.resolve(EMPTY_SUMMARY) };
return {
rawMaterials,
mixes,
productCosts,
scenarios,
dataQuality
};
} catch {
return {
rawMaterials: [],
mixes: [],
productCosts: [],
scenarios: [],
dataQuality: []
};
} }
return {
summary: api.dashboardSummary(fetch).catch(() => EMPTY_SUMMARY)
};
} }
@@ -151,14 +151,6 @@
const previewJson = $derived(JSON.stringify(exportPreview, null, 2)); const previewJson = $derived(JSON.stringify(exportPreview, null, 2));
</script> </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"> <section class="metric-row">
<article class="metric-card"> <article class="metric-card">
<span>Total Clients</span> <span>Total Clients</span>
@@ -18,16 +18,11 @@
} }
</script> </script>
<section class="page-intro"> {#if canEdit}
<div> <section class="page-actions">
<p class="eyebrow">Mix Calculator</p>
<h2>Saved production sessions</h2>
<p>Each session preserves the scaled raw material output so it can be reopened or printed later without relying on live recipe changes.</p>
</div>
{#if canEdit}
<a class="primary-button" href="/mix-calculator/new">New mix session</a> <a class="primary-button" href="/mix-calculator/new">New mix session</a>
{/if} </section>
</section> {/if}
<section class="metric-row"> <section class="metric-row">
<article class="metric-card"> <article class="metric-card">
@@ -123,27 +118,17 @@
text-transform: uppercase; text-transform: uppercase;
} }
.page-intro, .page-actions,
.metric-row, .metric-row,
.table-card { .table-card {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.page-intro { .page-actions {
display: flex; display: flex;
align-items: flex-start; justify-content: flex-end;
justify-content: space-between;
gap: 1rem;
} }
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
max-width: 15ch;
font-size: clamp(1.7rem, 3vw, 2.2rem);
font-weight: 700;
}
.page-intro p:last-child,
.metric-card p, .metric-card p,
.table-toolbar p, .table-toolbar p,
tbody span { tbody span {
@@ -283,7 +268,6 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.page-intro,
.table-toolbar { .table-toolbar {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
+8 -12
View File
@@ -104,16 +104,8 @@
}); });
</script> </script>
<section class="page-intro"> <section class="page-actions">
<div> <a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
<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> </section>
<section class="metric-row"> <section class="metric-row">
@@ -221,13 +213,17 @@
text-transform: uppercase; text-transform: uppercase;
} }
.page-intro, .page-actions,
.metric-row, .metric-row,
.table-card { .table-card {
margin-bottom: 1.12rem; margin-bottom: 1.12rem;
} }
.page-intro, .page-actions {
display: flex;
justify-content: flex-end;
}
.metric-card, .metric-card,
.table-card { .table-card {
background: var(--panel); background: var(--panel);
@@ -46,14 +46,6 @@
); );
</script> </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"> <section class="metric-row">
<article class="metric-card"> <article class="metric-card">
<span>Total Products</span> <span>Total Products</span>
@@ -152,19 +152,6 @@
<a href="/">Return to sign-in</a> <a href="/">Return to sign-in</a>
</section> </section>
{:else} {: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} {#if successMessage}
<p class="feedback success">{successMessage}</p> <p class="feedback success">{successMessage}</p>
{/if} {/if}
@@ -11,14 +11,6 @@
const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length); const approvedCount = $derived(scenarioRows.filter((scenario) => scenario.status === 'approved').length);
</script> </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"> <section class="metric-row">
<article class="metric-card"> <article class="metric-card">
<span>Total Scenarios</span> <span>Total Scenarios</span>
@@ -4,14 +4,6 @@
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
</script> </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"> <section class="settings-grid">
<article class="surface-card"> <article class="surface-card">
<p class="eyebrow">Session</p> <p class="eyebrow">Session</p>