Access permissions, seed permissions, security, session, api/session improved handling + speed across the site/UX improvements
This commit is contained in:
@@ -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())
|
||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,18 +108,27 @@ 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,
|
"product_id": product.id,
|
||||||
"client_name": product.client_name,
|
"client_name": product.client_name,
|
||||||
@@ -128,9 +137,10 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
|||||||
"mix_name": product.mix.name if product.mix else "",
|
"mix_name": product.mix.name if product.mix else "",
|
||||||
"unit_of_measure": product.unit_of_measure,
|
"unit_of_measure": product.unit_of_measure,
|
||||||
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
|
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
|
||||||
"mix_total_kg": mix_total_kg,
|
"mix_total_kg": mix_totals.get(product.mix_id, 0.0),
|
||||||
}
|
}
|
||||||
)
|
for product in products
|
||||||
|
]
|
||||||
|
|
||||||
return {"clients": clients, "products": product_rows}
|
return {"clients": clients, "products": product_rows}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
+12
-20
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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,92 +447,67 @@
|
|||||||
</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}
|
|
||||||
class:active={workingDocumentsActive}
|
|
||||||
class="nav-group-toggle"
|
|
||||||
type="button"
|
|
||||||
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
|
||||||
>
|
|
||||||
<span class="nav-group-toggle-copy">
|
|
||||||
<span class="nav-icon muted">WD</span>
|
|
||||||
<span>Working Docs</span>
|
|
||||||
</span>
|
|
||||||
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
|
||||||
</button>
|
|
||||||
{#if workingDocumentsExpanded}
|
|
||||||
<nav class="nav-sublist" id="working-documents-nav" aria-label="Working document pages">
|
|
||||||
{#each visibleWorkingDocumentItems as item}
|
{#each visibleWorkingDocumentItems 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="nav-icon">{item.shortLabel}</span>
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
{#if visibleFooterLinks.length}
|
||||||
|
<p class="nav-section-label">More</p>
|
||||||
|
<nav class="nav-list" aria-label="Workspace shortcuts">
|
||||||
{#each visibleFooterLinks as item}
|
{#each visibleFooterLinks as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
<a href={item.href}>
|
<a href={item.href}>
|
||||||
<span class="nav-icon muted">{item.shortLabel}</span>
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</nav>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<span class="sidebar-version-row">
|
{#if $clientSession}
|
||||||
|
<button class="sidebar-signout" type="button" onclick={() => clientSession.clear()}>
|
||||||
|
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
|
||||||
|
<span>Sign out</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<div class="sidebar-meta-foot">
|
||||||
|
<span class="version-pill">
|
||||||
<span>{appVersion}</span>
|
<span>{appVersion}</span>
|
||||||
<span class="release-pill">{releaseStage}</span>
|
<span class="release-pill">{releaseStage}</span>
|
||||||
</span>
|
</span>
|
||||||
<small>© {currentYear} Hunter Premium Produce</small>
|
<small>© {currentYear} Hunter Premium Produce</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -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"
|
|
||||||
aria-expanded={workingDocumentsExpanded}
|
|
||||||
class:active={workingDocumentsActive}
|
|
||||||
class="nav-group-toggle drawer-group-toggle"
|
|
||||||
type="button"
|
|
||||||
onclick={() => (workingDocumentsExpanded = !workingDocumentsExpanded)}
|
|
||||||
>
|
|
||||||
<span class="nav-group-toggle-copy">
|
|
||||||
<span class="nav-icon muted">WD</span>
|
|
||||||
<span>Working Documents</span>
|
|
||||||
</span>
|
|
||||||
<span class:open={workingDocumentsExpanded} class="chevron"></span>
|
|
||||||
</button>
|
|
||||||
{#if workingDocumentsExpanded}
|
|
||||||
<div id="drawer-working-documents-nav" class="drawer-sublist">
|
|
||||||
{#each visibleWorkingDocumentItems as item}
|
{#each visibleWorkingDocumentItems as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
|
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
|
||||||
<span class="nav-icon">{item.shortLabel}</span>
|
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<Icon size={44} strokeWidth={1.6} />
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
{@render greetIcon()}
|
||||||
|
<h2>{greeting.label}, {firstName($clientSession?.name)}</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2>{greetingForAst()}, {firstName($clientSession?.name)}</h2>
|
|
||||||
<p>Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.</p>
|
|
||||||
</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>
|
|
||||||
<h3>Hunter Premium Produce</h3>
|
|
||||||
<p>Lean 101 powers the client workspace while operator-only administration now lives in the separate `/admin` area.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="focus-grid">
|
|
||||||
{#each focusCards as card}
|
|
||||||
<article class={`focus-card ${card.tone}`}>
|
<article class={`focus-card ${card.tone}`}>
|
||||||
<span class="focus-code">{card.code}</span>
|
<span class="focus-code">{card.code}</span>
|
||||||
<div>
|
<div>
|
||||||
|
{#if loading}
|
||||||
|
<Skeleton width="9rem" height="0.95rem" />
|
||||||
|
<Skeleton width="6rem" height="0.7rem" />
|
||||||
|
{:else}
|
||||||
<strong>{card.label}</strong>
|
<strong>{card.label}</strong>
|
||||||
<span>{card.detail}</span>
|
<span>{card.detail}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if loading}
|
||||||
|
<Skeleton width="4rem" height="1rem" />
|
||||||
|
{:else}
|
||||||
<em>{card.value}</em>
|
<em>{card.value}</em>
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="dashboard-grid">
|
<section class="dashboard-grid">
|
||||||
@@ -451,13 +450,21 @@
|
|||||||
|
|
||||||
<div class="market-layout">
|
<div class="market-layout">
|
||||||
<div>
|
<div>
|
||||||
|
{#if loading}
|
||||||
|
<Skeleton width="14rem" height="1.5rem" />
|
||||||
|
<div style="height:0.5rem"></div>
|
||||||
|
<Skeleton width="8rem" height="0.85rem" />
|
||||||
|
<div class="hero-value"><Skeleton width="9rem" height="2.6rem" /></div>
|
||||||
|
<Skeleton width="11rem" height="0.85rem" />
|
||||||
|
{:else}
|
||||||
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3>
|
<h3>{featuredMaterial?.name ?? 'No material loaded'}</h3>
|
||||||
<p>{formatDate(featuredMaterial?.current_price?.effective_date)}</p>
|
<p>{formatDate(featuredMaterial?.effective_date)}</p>
|
||||||
<div class="hero-value">{currency(featuredMaterial?.current_price?.market_value)}</div>
|
<div class="hero-value">{currency(featuredMaterial?.market_value)}</div>
|
||||||
<p class="support-text">
|
<p class="support-text">
|
||||||
{currency(featuredMaterial?.current_price?.cost_per_kg, 4)} / kg
|
{currency(featuredMaterial?.cost_per_kg, 4)} / kg
|
||||||
<span>Current blend for Hunter Premium Produce</span>
|
<span>Current blend for Hunter Premium Produce</span>
|
||||||
</p>
|
</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>
|
||||||
|
{#if loading}
|
||||||
|
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
|
||||||
|
{:else}
|
||||||
<strong>{currency(totalMarketValue)}</strong>
|
<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>
|
||||||
|
{#if loading}
|
||||||
|
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
|
||||||
|
{:else}
|
||||||
<strong>{currency(averageMixCost, 4)}</strong>
|
<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>
|
||||||
|
{#if loading}
|
||||||
|
<strong><Skeleton width="6rem" height="1.6rem" /></strong>
|
||||||
|
<p><Skeleton width="9rem" height="0.85rem" /></p>
|
||||||
|
{:else}
|
||||||
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong>
|
<strong>{currency(featuredProduct?.finished_product_delivered)}</strong>
|
||||||
<p>{featuredProduct?.product_name ?? 'No products loaded'}</p>
|
<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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -104,16 +104,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="page-intro">
|
<section class="page-actions">
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Mix Master</p>
|
|
||||||
<h2>Saved mixes in a clean table view.</h2>
|
|
||||||
<p>Use the table to browse mixes, then open a dedicated worksheet page to edit or create a formulation.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="intro-actions">
|
|
||||||
<a class="primary-button" href="/mixes/new">New Mix Worksheet</a>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user