Compare commits
16
Commits
4ff372d307
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4d9d77e5 | ||
|
|
dc50e0538e | ||
|
|
c9f233dc0e | ||
|
|
87878e70fc | ||
|
|
696f1e7b09 | ||
|
|
10722a65a6 | ||
|
|
1062c038e8 | ||
|
|
e7a7b11589 | ||
|
|
1dd48bc771 | ||
|
|
3f8279af10 | ||
|
|
7db95e2027 | ||
|
|
8f9a7b8193 | ||
|
|
250d6ab6a9 | ||
|
|
8b81f804f7 | ||
|
|
b1c0d3f3da | ||
|
|
2de82776cb |
@@ -33,5 +33,12 @@ LOGIN_RATE_LIMIT_ATTEMPTS=8
|
||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
||||
DOCS_ENABLED=false
|
||||
|
||||
# Read-only Power BI / external data API at /api/v1. Set a long random key to
|
||||
# enable it; leave blank to disable the API entirely. Power BI sends this as an
|
||||
# "X-API-Key" header (or "?api_key=" query parameter).
|
||||
POWERBI_API_KEY=V7BI59yhRBF7VMiPNfgmqPxrsPuNuPFJ
|
||||
# Tenant the Power BI API reads from. Defaults to CLIENT_TENANT_ID.
|
||||
POWERBI_TENANT_ID=
|
||||
|
||||
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
|
||||
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
## Repository operations
|
||||
|
||||
### RUles for Svelte
|
||||
If a block has its own UI + state + behaviour, make it a component.
|
||||
If logic is reused or long, move it to a .ts utility file.
|
||||
If CSS is over 300–500 lines, split components.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Current app dependency entry points:
|
||||
|
||||
+547
-19
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.access import (
|
||||
@@ -65,6 +65,9 @@ class RoleRead(BaseModel):
|
||||
name: str
|
||||
description: str | None
|
||||
permissions: list[str]
|
||||
module_permissions: dict[str, str]
|
||||
is_protected: bool = False
|
||||
user_count: int = 0
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
@@ -73,6 +76,273 @@ class UserRead(BaseModel):
|
||||
name: str
|
||||
is_active: bool
|
||||
role: str | None
|
||||
role_id: int | None
|
||||
# True when this user can never be deleted (lean owner accounts). The UI
|
||||
# uses this to disable the delete control rather than re-deriving the rule.
|
||||
is_protected: bool = False
|
||||
|
||||
|
||||
class AssignableRole(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
|
||||
class RoleModuleDefinition(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
levels: list[str]
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
email: str
|
||||
name: str
|
||||
role_id: int | None = None
|
||||
is_active: bool = True
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class AdminUpdateUserRequest(BaseModel):
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
role_id: int | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AdminSetPasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class CreateRoleRequest(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
module_permissions: dict[str, str] = {}
|
||||
|
||||
|
||||
class UpdateRoleRequest(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
module_permissions: dict[str, str] | None = None
|
||||
|
||||
|
||||
# Lean owner accounts are permanent: they may be edited but never deleted, so a
|
||||
# tenant can't accidentally lock itself out of the highest level of access.
|
||||
LEAN_ROLE_NAME = "lean"
|
||||
ADMIN_ROLE_NAME = "admin"
|
||||
ROLE_MANAGEMENT_ALLOWED_ROLES = {LEAN_ROLE_NAME, ADMIN_ROLE_NAME}
|
||||
PROTECTED_ROLE_NAMES = ROLE_MANAGEMENT_ALLOWED_ROLES
|
||||
|
||||
ROLE_MODULE_DEFINITIONS: tuple[dict[str, object], ...] = (
|
||||
{
|
||||
"key": "dashboard",
|
||||
"label": "Dashboard",
|
||||
"description": "Home dashboard visibility.",
|
||||
"levels": {"none": (), "view": ("view_dashboard",)},
|
||||
},
|
||||
{
|
||||
"key": "mix_calculator",
|
||||
"label": "Mix Calculator",
|
||||
"description": "Open the calculator and save sessions.",
|
||||
"levels": {
|
||||
"none": (),
|
||||
"view": ("view_mix_calculator",),
|
||||
"edit": ("view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "raw_materials",
|
||||
"label": "Raw Materials",
|
||||
"description": "View or edit raw materials.",
|
||||
"levels": {"none": (), "view": ("view_raw_materials",), "edit": ("view_raw_materials", "edit_raw_materials")},
|
||||
},
|
||||
{
|
||||
"key": "products",
|
||||
"label": "Products",
|
||||
"description": "View or edit finished products.",
|
||||
"levels": {"none": (), "view": ("view_products",), "edit": ("view_products", "edit_products")},
|
||||
},
|
||||
{
|
||||
"key": "mix_master",
|
||||
"label": "Mix Master",
|
||||
"description": "View or edit mix recipes.",
|
||||
"levels": {"none": (), "view": ("view_mixes",), "edit": ("view_mixes", "edit_mixes")},
|
||||
},
|
||||
{
|
||||
"key": "operations_throughput",
|
||||
"label": "Throughput",
|
||||
"description": "View or edit throughput entries.",
|
||||
"levels": {"none": (), "view": ("view_throughput",), "edit": ("view_throughput", "edit_throughput")},
|
||||
},
|
||||
{
|
||||
"key": "ordering",
|
||||
"label": "Ordering",
|
||||
"description": "Access customer ordering and ordering administration.",
|
||||
"levels": {
|
||||
"none": (),
|
||||
"view": ("view_ordering",),
|
||||
"edit": ("view_ordering", "edit_ordering"),
|
||||
"manage": ("view_ordering", "edit_ordering", "manage_ordering"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "scenarios",
|
||||
"label": "Scenarios",
|
||||
"description": "View or run scenarios.",
|
||||
"levels": {"none": (), "view": ("view_scenarios",), "edit": ("view_scenarios", "edit_scenarios")},
|
||||
},
|
||||
{
|
||||
"key": "client_access",
|
||||
"label": "Client Access",
|
||||
"description": "Manage customer portal accounts and access.",
|
||||
"levels": {"none": (), "manage": ("manage_client_access",)},
|
||||
},
|
||||
{
|
||||
"key": "users",
|
||||
"label": "Users",
|
||||
"description": "View or manage internal users.",
|
||||
"levels": {"none": (), "view": ("view_users",), "manage": ("view_users", "manage_users")},
|
||||
},
|
||||
{
|
||||
"key": "roles",
|
||||
"label": "Roles",
|
||||
"description": "Manage roles and permission assignments.",
|
||||
"levels": {"none": (), "manage": ("manage_permissions",)},
|
||||
},
|
||||
{
|
||||
"key": "settings",
|
||||
"label": "Settings",
|
||||
"description": "Open settings and edit system configuration.",
|
||||
"levels": {"none": (), "view": ("view_settings",), "edit": ("view_settings", "edit_settings")},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _serialize_user_read(user: User) -> UserRead:
|
||||
role_name = user.role.name if user.role else None
|
||||
return UserRead(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
is_active=user.is_active,
|
||||
role=role_name,
|
||||
role_id=user.role_id,
|
||||
is_protected=(role_name or "").lower() == LEAN_ROLE_NAME,
|
||||
)
|
||||
|
||||
|
||||
def _role_name_lower(role: Role | None) -> str:
|
||||
return (role.name if role else "").strip().lower()
|
||||
|
||||
|
||||
def _is_protected_role_name(role_name: str | None) -> bool:
|
||||
return (role_name or "").strip().lower() in PROTECTED_ROLE_NAMES
|
||||
|
||||
|
||||
def _module_definitions_response() -> list[RoleModuleDefinition]:
|
||||
return [
|
||||
RoleModuleDefinition(
|
||||
key=definition["key"],
|
||||
label=definition["label"],
|
||||
description=definition["description"],
|
||||
levels=list(definition["levels"].keys()),
|
||||
)
|
||||
for definition in ROLE_MODULE_DEFINITIONS
|
||||
]
|
||||
|
||||
|
||||
def _permissions_to_role_module_map(permission_keys: set[str]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for definition in ROLE_MODULE_DEFINITIONS:
|
||||
selected = "none"
|
||||
levels = definition["levels"]
|
||||
for level, required in levels.items():
|
||||
required_keys = set(required)
|
||||
if not required_keys or required_keys.issubset(permission_keys):
|
||||
selected = level
|
||||
result[definition["key"]] = selected
|
||||
return result
|
||||
|
||||
|
||||
def _role_payload_to_permission_keys(module_permissions: dict[str, str]) -> set[str]:
|
||||
known_modules = {definition["key"] for definition in ROLE_MODULE_DEFINITIONS}
|
||||
unknown_modules = sorted(set(module_permissions) - known_modules)
|
||||
if unknown_modules:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Unknown modules: {unknown_modules}",
|
||||
)
|
||||
|
||||
granted: set[str] = set()
|
||||
for definition in ROLE_MODULE_DEFINITIONS:
|
||||
key = definition["key"]
|
||||
level = module_permissions.get(key, "none")
|
||||
available_levels: dict[str, tuple[str, ...]] = definition["levels"] # type: ignore[assignment]
|
||||
if level not in available_levels:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid access level '{level}' for module '{key}'",
|
||||
)
|
||||
granted.update(available_levels[level])
|
||||
return granted
|
||||
|
||||
|
||||
def _serialize_role_read(role: Role, *, user_count: int = 0) -> RoleRead:
|
||||
permission_keys = {permission.key for permission in role.permissions}
|
||||
return RoleRead(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
permissions=sorted(permission_keys),
|
||||
module_permissions=_permissions_to_role_module_map(permission_keys),
|
||||
is_protected=_is_protected_role_name(role.name),
|
||||
user_count=user_count,
|
||||
)
|
||||
|
||||
|
||||
def _require_role_management_actor(user: User = Depends(get_current_user)) -> User:
|
||||
if _role_name_lower(user.role) not in ROLE_MANAGEMENT_ALLOWED_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only lean and admin accounts can manage roles",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _load_role(db: Session, role_id: int) -> Role:
|
||||
role = db.scalar(
|
||||
select(Role).where(Role.id == role_id).options(selectinload(Role.permissions))
|
||||
)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
|
||||
return role
|
||||
|
||||
|
||||
def _apply_role_updates(
|
||||
role: Role,
|
||||
*,
|
||||
name: str | None,
|
||||
description: str | None,
|
||||
module_permissions: dict[str, str] | None,
|
||||
permissions_by_key: dict[str, Permission],
|
||||
) -> None:
|
||||
if name is not None:
|
||||
trimmed_name = name.strip()
|
||||
if not trimmed_name:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
|
||||
role.name = trimmed_name
|
||||
|
||||
if description is not None:
|
||||
role.description = description.strip() or None
|
||||
|
||||
if module_permissions is not None:
|
||||
desired_permission_keys = _role_payload_to_permission_keys(module_permissions)
|
||||
desired = {permissions_by_key[key] for key in desired_permission_keys}
|
||||
current = set(role.permissions)
|
||||
for permission in desired - current:
|
||||
role.permissions.append(permission)
|
||||
for permission in current - desired:
|
||||
role.permissions.remove(permission)
|
||||
|
||||
|
||||
def _serialize_session(user: User, *, include_token: bool = False) -> UserSession:
|
||||
@@ -216,36 +486,294 @@ 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()
|
||||
users = db.scalars(
|
||||
select(User).options(selectinload(User.role)).order_by(User.name)
|
||||
).all()
|
||||
return [_serialize_user_read(user) for user in users]
|
||||
|
||||
|
||||
@router.get("/assignable-roles", response_model=list[AssignableRole])
|
||||
def list_assignable_roles(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Roles that a user-manager can assign — used to populate the role picker.
|
||||
|
||||
Separate from ``/roles`` (which exposes full permission sets and is gated by
|
||||
``manage_permissions``); managing users only needs the role list itself.
|
||||
"""
|
||||
roles = db.scalars(select(Role).order_by(Role.name)).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
|
||||
AssignableRole(id=role.id, name=role.name, description=role.description)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _load_managed_user(db: Session, user_id: int) -> User:
|
||||
user = db.scalar(
|
||||
select(User).where(User.id == user_id).options(selectinload(User.role))
|
||||
)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def _resolve_role(db: Session, role_id: int | None) -> Role | None:
|
||||
if role_id is None:
|
||||
return None
|
||||
role = db.scalar(select(Role).where(Role.id == role_id))
|
||||
if role is None:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown role")
|
||||
return role
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
payload: CreateUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Create a new internal user.
|
||||
|
||||
A user with no password can still sign in with the shared internal password
|
||||
until they set a personal one in their own settings.
|
||||
"""
|
||||
email = payload.email.strip().lower()
|
||||
if not email or "@" not in email:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
|
||||
if db.scalar(select(User).where(User.email == email)):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
|
||||
|
||||
role = _resolve_role(db, payload.role_id)
|
||||
|
||||
password_hash = None
|
||||
if payload.password:
|
||||
if len(payload.password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Password must be at least 8 characters",
|
||||
)
|
||||
password_hash = hash_password(payload.password)
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
name=name,
|
||||
role_id=role.id if role else None,
|
||||
is_active=payload.is_active,
|
||||
password_hash=password_hash,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_security_event("users.created", audience="internal", actor_user_id=actor.id, user_id=user.id)
|
||||
return _serialize_user_read(user)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserRead)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
payload: AdminUpdateUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Update another user's name, email, role, or active status."""
|
||||
user = _load_managed_user(db, user_id)
|
||||
|
||||
if payload.is_active is not None:
|
||||
# Guard against locking yourself out of your own management session.
|
||||
if user.id == actor.id and not payload.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot deactivate your own account",
|
||||
)
|
||||
user.is_active = payload.is_active
|
||||
|
||||
if payload.name is not None:
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
|
||||
user.name = name
|
||||
|
||||
if payload.email is not None:
|
||||
email = payload.email.strip().lower()
|
||||
if not email or "@" not in email:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
|
||||
existing = db.scalar(select(User).where(User.email == email, User.id != user.id))
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
|
||||
user.email = email
|
||||
|
||||
if payload.role_id is not None:
|
||||
role = _resolve_role(db, payload.role_id)
|
||||
user.role_id = role.id if role else None
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_security_event("users.updated", audience="internal", actor_user_id=actor.id, user_id=user.id)
|
||||
return _serialize_user_read(user)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password", response_model=UserRead)
|
||||
def set_user_password(
|
||||
user_id: int,
|
||||
payload: AdminSetPasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Set (reset) another user's password without their current password."""
|
||||
user = _load_managed_user(db, user_id)
|
||||
if len(payload.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="New password must be at least 8 characters",
|
||||
)
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_security_event("users.password_reset", audience="internal", actor_user_id=actor.id, user_id=user.id)
|
||||
return _serialize_user_read(user)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Delete a user. Lean owner accounts and your own account are protected."""
|
||||
user = _load_managed_user(db, user_id)
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot delete your own account",
|
||||
)
|
||||
if (user.role.name if user.role else "").lower() == LEAN_ROLE_NAME:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Lean accounts cannot be deleted",
|
||||
)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
log_security_event("users.deleted", audience="internal", actor_user_id=actor.id, user_id=user_id)
|
||||
response.status_code = status.HTTP_204_NO_CONTENT
|
||||
return None
|
||||
|
||||
|
||||
@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
|
||||
_: User = Depends(_require_role_management_actor),
|
||||
):
|
||||
user_counts = dict(
|
||||
db.execute(select(User.role_id, func.count(User.id)).group_by(User.role_id)).all()
|
||||
)
|
||||
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),
|
||||
return [_serialize_role_read(role, user_count=user_counts.get(role.id, 0)) for role in roles]
|
||||
|
||||
|
||||
@router.get("/role-modules", response_model=list[RoleModuleDefinition])
|
||||
def list_role_modules(_: User = Depends(_require_role_management_actor)):
|
||||
return _module_definitions_response()
|
||||
|
||||
|
||||
@router.post("/roles", response_model=RoleRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_role(
|
||||
payload: CreateRoleRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(_require_role_management_actor),
|
||||
):
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
|
||||
existing = db.scalar(select(Role).where(func.lower(Role.name) == name.lower()))
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
|
||||
|
||||
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
|
||||
role = Role(name=name, description=None)
|
||||
db.add(role)
|
||||
db.flush()
|
||||
_apply_role_updates(
|
||||
role,
|
||||
name=name,
|
||||
description=payload.description,
|
||||
module_permissions=payload.module_permissions,
|
||||
permissions_by_key=permissions_by_key,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
log_security_event("roles.created", audience="internal", actor_user_id=actor.id, role_id=role.id)
|
||||
return _serialize_role_read(role, user_count=0)
|
||||
|
||||
|
||||
@router.patch("/roles/{role_id}", response_model=RoleRead)
|
||||
def update_role(
|
||||
role_id: int,
|
||||
payload: UpdateRoleRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(_require_role_management_actor),
|
||||
):
|
||||
role = _load_role(db, role_id)
|
||||
original_name = role.name
|
||||
protected = _is_protected_role_name(original_name)
|
||||
requested_name = payload.name.strip() if payload.name is not None else role.name
|
||||
if protected and requested_name.lower() != original_name.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Lean and admin roles cannot be renamed",
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
if payload.name is not None:
|
||||
duplicate = db.scalar(
|
||||
select(Role).where(func.lower(Role.name) == requested_name.lower(), Role.id != role.id)
|
||||
)
|
||||
if duplicate:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
|
||||
|
||||
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
|
||||
_apply_role_updates(
|
||||
role,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
module_permissions=payload.module_permissions,
|
||||
permissions_by_key=permissions_by_key,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
user_count = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
|
||||
log_security_event("roles.updated", audience="internal", actor_user_id=actor.id, role_id=role.id)
|
||||
return _serialize_role_read(role, user_count=user_count)
|
||||
|
||||
|
||||
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_role(
|
||||
role_id: int,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(_require_role_management_actor),
|
||||
):
|
||||
role = _load_role(db, role_id)
|
||||
if _is_protected_role_name(role.name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Lean and admin roles cannot be deleted",
|
||||
)
|
||||
assigned_users = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
|
||||
if assigned_users > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Reassign users before deleting this role",
|
||||
)
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
log_security_event("roles.deleted", audience="internal", actor_user_id=actor.id, role_id=role_id)
|
||||
response.status_code = status.HTTP_204_NO_CONTENT
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/permissions", response_model=list[str])
|
||||
|
||||
+709
-11
@@ -1,22 +1,43 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
|
||||
from app.api.deps import AuthSession, get_auth_session
|
||||
from app.db.session import get_db
|
||||
from app.models.mix import Mix
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product, ProductIngredient
|
||||
from app.models.raw_material import RawMaterial
|
||||
from app.models.change_event import EditorChangeEvent
|
||||
from app.schemas.editor import (
|
||||
EditorChangeEventRead,
|
||||
EditorIngredientCreate,
|
||||
EditorIngredientRow,
|
||||
EditorIngredientUpdate,
|
||||
EditorMixFormulaRead,
|
||||
EditorMixCreate,
|
||||
EditorMixFormulaReplace,
|
||||
EditorMixIngredientCreate,
|
||||
EditorMixIngredientUpdate,
|
||||
EditorMixRow,
|
||||
EditorMixUpdate,
|
||||
EditorProductFormulaRead,
|
||||
EditorProductIngredientCreate,
|
||||
EditorProductIngredientUpdate,
|
||||
EditorProductRow,
|
||||
EditorProductUpdate,
|
||||
EditorResolvedMixFormula,
|
||||
)
|
||||
from app.services.change_log import (
|
||||
ENTITY_INGREDIENT,
|
||||
ENTITY_MIX,
|
||||
diff_fields,
|
||||
list_changes,
|
||||
record_change,
|
||||
)
|
||||
from app.services.client_access_service import has_access_level
|
||||
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
|
||||
from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product
|
||||
|
||||
router = APIRouter(prefix="/api/editor", tags=["editor"])
|
||||
|
||||
@@ -63,6 +84,118 @@ def _serialize_product_formula(product: Product) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
|
||||
# Status is product-driven once a mix has products (Active = at least one
|
||||
# visible product). A mix with no products yet has nothing to fan out to, so
|
||||
# it falls back to its own `status` column — that's what lets a brand-new
|
||||
# mix read as Active instead of being stuck Inactive and hidden.
|
||||
visible = visible_count > 0 if product_count > 0 else mix.status == "active"
|
||||
return {
|
||||
"id": mix.id,
|
||||
"tenant_id": mix.tenant_id,
|
||||
"client_name": mix.client_name,
|
||||
"name": mix.name,
|
||||
"visible": visible,
|
||||
"product_count": product_count,
|
||||
"visible_product_count": visible_count,
|
||||
"notes": mix.notes,
|
||||
}
|
||||
|
||||
|
||||
def _mix_product_counts(db: Session, tenant_id: str) -> dict[int, tuple[int, int]]:
|
||||
"""Per-mix (total products, visible products) used to drive the Status column."""
|
||||
rows = db.execute(
|
||||
select(
|
||||
Product.mix_id,
|
||||
func.count(),
|
||||
func.sum(case((Product.visible, 1), else_=0)),
|
||||
)
|
||||
.where(Product.tenant_id == tenant_id)
|
||||
.group_by(Product.mix_id)
|
||||
).all()
|
||||
return {mix_id: (int(total), int(visible or 0)) for mix_id, total, visible in rows}
|
||||
|
||||
|
||||
def _serialize_mix_formula(mix: Mix) -> dict:
|
||||
ingredients = [
|
||||
{
|
||||
"id": ingredient.id,
|
||||
"raw_material_id": ingredient.raw_material_id,
|
||||
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"notes": ingredient.notes,
|
||||
}
|
||||
for ingredient in sorted(
|
||||
mix.ingredients,
|
||||
key=lambda item: item.raw_material.name if item.raw_material else "",
|
||||
)
|
||||
]
|
||||
return {
|
||||
"id": mix.id,
|
||||
"tenant_id": mix.tenant_id,
|
||||
"client_name": mix.client_name,
|
||||
"name": mix.name,
|
||||
"ingredients": ingredients,
|
||||
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_change_event(event: EditorChangeEvent) -> dict:
|
||||
return {
|
||||
"id": event.id,
|
||||
"entity_type": event.entity_type,
|
||||
"entity_id": event.entity_id,
|
||||
"action": event.action,
|
||||
"actor_name": event.actor_name,
|
||||
"actor_email": event.actor_email,
|
||||
"actor_role": event.actor_role,
|
||||
"summary": event.summary,
|
||||
"changes": event.changes or [],
|
||||
"created_at": event.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _format_kg(value: float) -> str:
|
||||
text = f"{value:.4f}".rstrip("0").rstrip(".")
|
||||
return f"{text or '0'} kg"
|
||||
|
||||
|
||||
def _formula_deltas(
|
||||
before: list[dict],
|
||||
after: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Per-ingredient before/after deltas between two resolved formulas.
|
||||
|
||||
`resolve_editor_mix_formula` returns plain dicts (ingredients are dicts too),
|
||||
so read the rows by key, not attribute.
|
||||
"""
|
||||
before_map = {row["raw_material_name"]: row["quantity_kg"] for row in before}
|
||||
after_map = {row["raw_material_name"]: row["quantity_kg"] for row in after}
|
||||
deltas: list[dict] = []
|
||||
for name in sorted(set(before_map) | set(after_map)):
|
||||
old = before_map.get(name)
|
||||
new = after_map.get(name)
|
||||
if old == new:
|
||||
continue
|
||||
deltas.append(
|
||||
{
|
||||
"field": name,
|
||||
"label": name,
|
||||
"before": _format_kg(old) if old is not None else None,
|
||||
"after": _format_kg(new) if new is not None else None,
|
||||
}
|
||||
)
|
||||
return deltas
|
||||
|
||||
|
||||
def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None:
|
||||
return db.scalar(
|
||||
select(Mix)
|
||||
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
|
||||
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
|
||||
)
|
||||
|
||||
|
||||
def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None:
|
||||
return db.scalar(
|
||||
select(Product)
|
||||
@@ -156,7 +289,65 @@ def update_editor_product(
|
||||
return _serialize_row(product)
|
||||
|
||||
|
||||
@router.patch("/mixes/{mix_id}", response_model=list[EditorProductRow])
|
||||
@router.get("/mixes", response_model=list[EditorMixRow])
|
||||
def list_editor_mixes(
|
||||
q: str | None = Query(default=None, max_length=255),
|
||||
client_name: str | None = Query(default=None, max_length=255),
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
statement = select(Mix).where(Mix.tenant_id == session.tenant_id)
|
||||
|
||||
if client_name:
|
||||
statement = statement.where(Mix.client_name == client_name)
|
||||
|
||||
if q:
|
||||
term = f"%{q.strip()}%"
|
||||
statement = statement.where(or_(Mix.client_name.ilike(term), Mix.name.ilike(term)))
|
||||
|
||||
statement = statement.order_by(Mix.client_name, Mix.name, Mix.id).limit(limit)
|
||||
|
||||
counts = _mix_product_counts(db, session.tenant_id or "")
|
||||
mixes = db.scalars(statement).all()
|
||||
return [
|
||||
_serialize_mix_row(mix, visible_count=counts.get(mix.id, (0, 0))[1], product_count=counts.get(mix.id, (0, 0))[0])
|
||||
for mix in mixes
|
||||
]
|
||||
|
||||
|
||||
@router.post("/mixes", response_model=EditorMixRow, status_code=201)
|
||||
def create_editor_mix(
|
||||
payload: EditorMixCreate,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
mix = Mix(
|
||||
tenant_id=session.tenant_id or "",
|
||||
client_name=payload.client_name.strip(),
|
||||
name=payload.name.strip(),
|
||||
notes=payload.notes,
|
||||
# Active by default so a freshly created mix shows under the default
|
||||
# "Active" filter rather than being hidden until it has a visible product.
|
||||
status="active",
|
||||
)
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix.id,
|
||||
action="created",
|
||||
summary=f"Created mix “{mix.name}” for {mix.client_name}",
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(mix)
|
||||
# A brand-new mix has no products yet, so it reads as Inactive (no visible products).
|
||||
return _serialize_mix_row(mix, visible_count=0, product_count=0)
|
||||
|
||||
|
||||
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
|
||||
def update_editor_mix(
|
||||
mix_id: int,
|
||||
payload: EditorMixUpdate,
|
||||
@@ -167,18 +358,361 @@ def update_editor_mix(
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
# `visible` is a virtual field: for a mix with products it fans out to the
|
||||
# visibility of every product; for a product-less mix it maps to the mix's
|
||||
# own `status` column so the toggle still persists.
|
||||
visible = updates.pop("visible", None)
|
||||
|
||||
product_total = (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Product)
|
||||
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
before = {field: getattr(mix, field) for field in updates}
|
||||
if visible is not None:
|
||||
if product_total > 0:
|
||||
visible_before = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Product)
|
||||
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
|
||||
)
|
||||
before["visible"] = bool(visible_before)
|
||||
else:
|
||||
before["visible"] = mix.status == "active"
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(mix, field, value)
|
||||
|
||||
if visible is not None:
|
||||
if product_total > 0:
|
||||
for product in db.scalars(
|
||||
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||
).all():
|
||||
product.visible = visible
|
||||
else:
|
||||
mix.status = "active" if visible else "inactive"
|
||||
|
||||
after = dict(updates)
|
||||
if visible is not None:
|
||||
after["visible"] = visible
|
||||
deltas = diff_fields(
|
||||
before,
|
||||
after,
|
||||
{"name": "Mix name", "client_name": "Client", "notes": "Notes", "visible": "Status (active)"},
|
||||
)
|
||||
if deltas:
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix.id,
|
||||
action="updated",
|
||||
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
|
||||
changes=deltas,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||
.options(joinedload(Product.mix))
|
||||
.order_by(Product.client_name, Product.name, Product.id)
|
||||
).all()
|
||||
return [_serialize_row(product) for product in products]
|
||||
counts = _mix_product_counts(db, session.tenant_id or "")
|
||||
total, visible_count = counts.get(mix_id, (0, 0))
|
||||
return _serialize_mix_row(mix, visible_count=visible_count, product_count=total)
|
||||
|
||||
|
||||
@router.delete("/mixes/{mix_id}", status_code=204)
|
||||
def delete_editor_mix(
|
||||
mix_id: int,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a mix that no product depends on.
|
||||
|
||||
A product must reference a mix (`products.mix_id` is NOT NULL), so a mix that
|
||||
still drives products can't be removed without orphaning them — those should
|
||||
be marked inactive instead. The mix's own ingredient rows cascade away with
|
||||
it via the `delete-orphan` relationship.
|
||||
"""
|
||||
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
|
||||
product_total = (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Product)
|
||||
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if product_total > 0:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"This mix has {product_total} linked product"
|
||||
f"{'s' if product_total != 1 else ''}. Mark it inactive or remove its products first."
|
||||
),
|
||||
)
|
||||
|
||||
db.delete(mix)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead)
|
||||
def get_editor_mix_ingredients(
|
||||
mix_id: int,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
return _serialize_mix_formula(mix)
|
||||
|
||||
|
||||
@router.post("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead, status_code=201)
|
||||
def add_editor_mix_ingredient(
|
||||
mix_id: int,
|
||||
payload: EditorMixIngredientCreate,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
raw_material = db.scalar(
|
||||
select(RawMaterial).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)
|
||||
)
|
||||
if raw_material is None:
|
||||
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||
|
||||
db.add(
|
||||
MixIngredient(
|
||||
tenant_id=session.tenant_id or "",
|
||||
mix_id=mix_id,
|
||||
raw_material_id=payload.raw_material_id,
|
||||
quantity_kg=payload.quantity_kg,
|
||||
notes=payload.notes,
|
||||
)
|
||||
)
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix_id,
|
||||
action="ingredient_added",
|
||||
summary=f"Added {raw_material.name} ({_format_kg(payload.quantity_kg)})",
|
||||
changes=[{"field": raw_material.name, "label": raw_material.name, "before": None, "after": _format_kg(payload.quantity_kg)}],
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="Raw material is already on this mix") from exc
|
||||
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||
return _serialize_mix_formula(mix)
|
||||
|
||||
|
||||
@router.patch("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
|
||||
def update_editor_mix_ingredient(
|
||||
mix_id: int,
|
||||
ingredient_id: int,
|
||||
payload: EditorMixIngredientUpdate,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ingredient = db.scalar(
|
||||
select(MixIngredient)
|
||||
.join(Mix)
|
||||
.where(
|
||||
MixIngredient.id == ingredient_id,
|
||||
MixIngredient.mix_id == mix_id,
|
||||
Mix.tenant_id == session.tenant_id,
|
||||
)
|
||||
)
|
||||
if ingredient is None:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
before = {field: getattr(ingredient, field) for field in updates}
|
||||
for field, value in updates.items():
|
||||
setattr(ingredient, field, value)
|
||||
deltas = diff_fields(before, updates, {"quantity_kg": f"{raw_material_name} quantity", "notes": f"{raw_material_name} notes"})
|
||||
if deltas:
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix_id,
|
||||
action="ingredient_updated",
|
||||
summary=f"Updated {raw_material_name}",
|
||||
changes=deltas,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||
return _serialize_mix_formula(mix)
|
||||
|
||||
|
||||
@router.delete("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
|
||||
def delete_editor_mix_ingredient(
|
||||
mix_id: int,
|
||||
ingredient_id: int,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ingredient = db.scalar(
|
||||
select(MixIngredient)
|
||||
.join(Mix)
|
||||
.where(
|
||||
MixIngredient.id == ingredient_id,
|
||||
MixIngredient.mix_id == mix_id,
|
||||
Mix.tenant_id == session.tenant_id,
|
||||
)
|
||||
)
|
||||
if ingredient is None:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
|
||||
removed_kg = ingredient.quantity_kg
|
||||
db.delete(ingredient)
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix_id,
|
||||
action="ingredient_removed",
|
||||
summary=f"Removed {raw_material_name}",
|
||||
changes=[{"field": raw_material_name, "label": raw_material_name, "before": _format_kg(removed_kg), "after": None}],
|
||||
)
|
||||
db.commit()
|
||||
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||
return _serialize_mix_formula(mix)
|
||||
|
||||
|
||||
@router.get("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
|
||||
def get_editor_mix_resolved_formula(
|
||||
mix_id: int,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""The mix formula as the Mix Calculator reads it (product-first resolution).
|
||||
|
||||
This is what the Mix Editor displays, so the two surfaces show identical
|
||||
ingredients and quantities. See `resolve_editor_mix_formula`.
|
||||
"""
|
||||
tenant_id = session.tenant_id or ""
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||
|
||||
|
||||
@router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
|
||||
def replace_editor_mix_formula(
|
||||
mix_id: int,
|
||||
payload: EditorMixFormulaReplace,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Replace a mix's whole formula in one save.
|
||||
|
||||
Writes back to the *same source* the Mix Calculator reads: the representative
|
||||
product's own formula (`ProductIngredient`) when it has one, otherwise the
|
||||
shared mix master (`MixIngredient`). Either way the calculator immediately
|
||||
reflects the edit.
|
||||
"""
|
||||
tenant_id = session.tenant_id or ""
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
|
||||
# Snapshot the formula as it stands so we can diff it against the saved one.
|
||||
before_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||
|
||||
raw_ids = [row.raw_material_id for row in payload.rows]
|
||||
if len(set(raw_ids)) != len(raw_ids):
|
||||
raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
|
||||
existing_ids = set(
|
||||
db.scalars(
|
||||
select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids))
|
||||
).all()
|
||||
)
|
||||
missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids]
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||
|
||||
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id)
|
||||
if product is not None and product.ingredients:
|
||||
# Replace the representative product's own formula.
|
||||
for ingredient in list(product.ingredients):
|
||||
db.delete(ingredient)
|
||||
db.flush()
|
||||
for sort_order, row in enumerate(payload.rows, start=1):
|
||||
db.add(
|
||||
ProductIngredient(
|
||||
tenant_id=tenant_id,
|
||||
product_id=product.id,
|
||||
raw_material_id=row.raw_material_id,
|
||||
quantity_kg=row.quantity_kg,
|
||||
sort_order=sort_order,
|
||||
notes=row.notes,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# No product-specific formula in play: edit the shared mix master, which
|
||||
# is what the calculator falls back to for this mix.
|
||||
for ingredient in list(mix.ingredients):
|
||||
db.delete(ingredient)
|
||||
db.flush()
|
||||
for row in payload.rows:
|
||||
db.add(
|
||||
MixIngredient(
|
||||
tenant_id=tenant_id,
|
||||
mix_id=mix.id,
|
||||
raw_material_id=row.raw_material_id,
|
||||
quantity_kg=row.quantity_kg,
|
||||
notes=row.notes,
|
||||
)
|
||||
)
|
||||
|
||||
db.flush()
|
||||
# Drop now-stale ORM state so the re-resolve reads the rows we just wrote
|
||||
# rather than the formerly-loaded ingredient collections from the identity map.
|
||||
db.expire_all()
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||
after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||
deltas = _formula_deltas(before_formula["ingredients"], after_formula["ingredients"])
|
||||
if deltas:
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix_id,
|
||||
action="formula_updated",
|
||||
summary=f"Updated formula ({len(deltas)} ingredient {'change' if len(deltas) == 1 else 'changes'})",
|
||||
changes=deltas,
|
||||
)
|
||||
db.commit()
|
||||
return after_formula
|
||||
|
||||
|
||||
@router.get("/mixes/{mix_id}/history", response_model=list[EditorChangeEventRead])
|
||||
def get_editor_mix_history(
|
||||
mix_id: int,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_id = session.tenant_id or ""
|
||||
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == tenant_id)) is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_MIX, entity_id=mix_id)
|
||||
return [_serialize_change_event(event) for event in events]
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
|
||||
@@ -282,3 +816,167 @@ def delete_editor_product_ingredient(
|
||||
|
||||
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
|
||||
return _serialize_product_formula(product)
|
||||
|
||||
|
||||
# --- Ingredients (raw materials) catalogue -----------------------------------
|
||||
#
|
||||
# The mix editor consumes raw materials as the ingredients dropdown; this gives
|
||||
# Lean admins a sibling editor to curate that catalogue — the ingredients that
|
||||
# ultimately get used inside mixes. Same auth/tenant model as the mix editor.
|
||||
|
||||
|
||||
def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
|
||||
active_price = get_active_price(material)
|
||||
cost_per_kg = (
|
||||
calculate_raw_material_cost(material, active_price).cost_per_kg if active_price is not None else None
|
||||
)
|
||||
return {
|
||||
"id": material.id,
|
||||
"name": material.name,
|
||||
"supplier": material.supplier,
|
||||
"unit_of_measure": material.unit_of_measure,
|
||||
"kg_per_unit": material.kg_per_unit,
|
||||
"status": material.status,
|
||||
"rounding_decimals": material.rounding_decimals,
|
||||
"category": material.category,
|
||||
"notes": material.notes,
|
||||
"cost_per_kg": cost_per_kg,
|
||||
"usage_count": usage_count,
|
||||
"created_at": material.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _ingredient_usage_counts(db: Session, tenant_id: str) -> dict[int, int]:
|
||||
"""How many product/mix formula rows reference each raw material."""
|
||||
rows = db.execute(
|
||||
select(ProductIngredient.raw_material_id, func.count())
|
||||
.where(ProductIngredient.tenant_id == tenant_id)
|
||||
.group_by(ProductIngredient.raw_material_id)
|
||||
).all()
|
||||
return {raw_material_id: count for raw_material_id, count in rows}
|
||||
|
||||
|
||||
@router.get("/ingredients", response_model=list[EditorIngredientRow])
|
||||
def list_editor_ingredients(
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_id = session.tenant_id or ""
|
||||
materials = db.scalars(
|
||||
select(RawMaterial)
|
||||
.where(RawMaterial.tenant_id == tenant_id)
|
||||
.options(selectinload(RawMaterial.price_versions))
|
||||
.order_by(RawMaterial.name)
|
||||
).all()
|
||||
usage = _ingredient_usage_counts(db, tenant_id)
|
||||
return [_serialize_ingredient(material, usage.get(material.id, 0)) for material in materials]
|
||||
|
||||
|
||||
@router.post("/ingredients", response_model=EditorIngredientRow, status_code=201)
|
||||
def create_editor_ingredient(
|
||||
payload: EditorIngredientCreate,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
material = RawMaterial(
|
||||
tenant_id=session.tenant_id or "",
|
||||
name=payload.name.strip(),
|
||||
supplier=(payload.supplier or "").strip() or None,
|
||||
unit_of_measure=payload.unit_of_measure.strip(),
|
||||
kg_per_unit=payload.kg_per_unit,
|
||||
status=payload.status.strip() or "active",
|
||||
rounding_decimals=payload.rounding_decimals,
|
||||
category=(payload.category or "").strip() or None,
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(material)
|
||||
try:
|
||||
db.flush()
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_INGREDIENT,
|
||||
entity_id=material.id,
|
||||
action="created",
|
||||
summary=f"Created ingredient “{material.name}”",
|
||||
)
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
|
||||
db.refresh(material)
|
||||
return _serialize_ingredient(material, 0)
|
||||
|
||||
|
||||
@router.patch("/ingredients/{ingredient_id}", response_model=EditorIngredientRow)
|
||||
def update_editor_ingredient(
|
||||
ingredient_id: int,
|
||||
payload: EditorIngredientUpdate,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_id = session.tenant_id or ""
|
||||
material = db.scalar(
|
||||
select(RawMaterial)
|
||||
.where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)
|
||||
.options(selectinload(RawMaterial.price_versions))
|
||||
)
|
||||
if material is None:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if "name" in updates and updates["name"] is not None:
|
||||
updates["name"] = updates["name"].strip()
|
||||
if "supplier" in updates:
|
||||
updates["supplier"] = (updates["supplier"] or "").strip() or None
|
||||
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
|
||||
updates["unit_of_measure"] = updates["unit_of_measure"].strip()
|
||||
if "category" in updates:
|
||||
updates["category"] = (updates["category"] or "").strip() or None
|
||||
before = {field: getattr(material, field) for field in updates}
|
||||
for field, value in updates.items():
|
||||
setattr(material, field, value)
|
||||
deltas = diff_fields(
|
||||
before,
|
||||
updates,
|
||||
{
|
||||
"name": "Name",
|
||||
"supplier": "Supplier",
|
||||
"unit_of_measure": "Unit of measure",
|
||||
"kg_per_unit": "Kg per unit",
|
||||
"status": "Status",
|
||||
"rounding_decimals": "Rounding (dp)",
|
||||
"category": "Category",
|
||||
"notes": "Notes",
|
||||
},
|
||||
)
|
||||
if deltas:
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_INGREDIENT,
|
||||
entity_id=material.id,
|
||||
action="updated",
|
||||
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
|
||||
changes=deltas,
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
|
||||
db.refresh(material)
|
||||
usage = _ingredient_usage_counts(db, tenant_id)
|
||||
return _serialize_ingredient(material, usage.get(material.id, 0))
|
||||
|
||||
|
||||
@router.get("/ingredients/{ingredient_id}/history", response_model=list[EditorChangeEventRead])
|
||||
def get_editor_ingredient_history(
|
||||
ingredient_id: int,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_id = session.tenant_id or ""
|
||||
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)) is None:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_INGREDIENT, entity_id=ingredient_id)
|
||||
return [_serialize_change_event(event) for event in events]
|
||||
|
||||
@@ -7,9 +7,10 @@ within the seller's ordering tenant.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
@@ -27,6 +28,7 @@ from app.models.ordering import (
|
||||
PriceListItem,
|
||||
PriceTier,
|
||||
ProductCategory,
|
||||
XeroContactLink,
|
||||
XeroSyncLog,
|
||||
)
|
||||
from app.schemas.ordering import (
|
||||
@@ -47,6 +49,7 @@ from app.schemas.ordering import (
|
||||
PriceListItemUpsert,
|
||||
ReopenOrderRequest,
|
||||
VisibilityUpdate,
|
||||
XeroContactLinkUpsert,
|
||||
)
|
||||
from app.services import ordering_service as svc
|
||||
from app.services.client_access_service import (
|
||||
@@ -54,7 +57,11 @@ from app.services.client_access_service import (
|
||||
record_audit_event,
|
||||
)
|
||||
from app.services.order_notifications import get_or_create_settings
|
||||
from app.services.xero_service import submit_order_to_xero, xero_status_snapshot
|
||||
from app.services.xero_service import (
|
||||
list_xero_contacts,
|
||||
submit_order_to_xero,
|
||||
xero_status_snapshot,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/ordering-admin", tags=["ordering-admin"])
|
||||
|
||||
@@ -122,11 +129,21 @@ def _serialize_tiers(db: Session, *, customer_product_price_id=None, price_list_
|
||||
# --- Customers ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _xero_link_for(db: Session, customer_id: int) -> XeroContactLink | None:
|
||||
return db.scalar(
|
||||
select(XeroContactLink).where(
|
||||
XeroContactLink.tenant_id == TENANT,
|
||||
XeroContactLink.client_account_id == customer_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _serialize_customer(db: Session, account: ClientAccount) -> dict:
|
||||
users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all()
|
||||
assignment = db.scalar(
|
||||
select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id)
|
||||
)
|
||||
link = _xero_link_for(db, account.id)
|
||||
return {
|
||||
"id": account.id,
|
||||
"name": account.name,
|
||||
@@ -137,6 +154,8 @@ def _serialize_customer(db: Session, account: ClientAccount) -> dict:
|
||||
"user_count": len(users),
|
||||
"price_list_id": assignment.price_list_id if assignment else None,
|
||||
"discount_percent": assignment.discount_percent if assignment else 0.0,
|
||||
"xero_contact_id": link.xero_contact_id if link else None,
|
||||
"xero_contact_name": link.xero_contact_name if link else None,
|
||||
"created_at": account.created_at,
|
||||
}
|
||||
|
||||
@@ -905,7 +924,10 @@ def send_order_to_xero(
|
||||
if order.status not in {"confirmed", "in_production", "sent_to_xero"}:
|
||||
raise HTTPException(status_code=409, detail="Only confirmed orders can be sent to Xero")
|
||||
account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
|
||||
result = submit_order_to_xero(order, account)
|
||||
link = _xero_link_for(db, order.client_account_id)
|
||||
result = submit_order_to_xero(order, account, link)
|
||||
if link is not None and result.status == "success":
|
||||
link.last_synced_at = datetime.utcnow()
|
||||
|
||||
db.add(
|
||||
XeroSyncLog(
|
||||
@@ -985,8 +1007,19 @@ def get_xero_status(
|
||||
recent = db.scalars(
|
||||
select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20)
|
||||
).all()
|
||||
total_customers = db.scalar(
|
||||
select(func.count()).select_from(ClientAccount)
|
||||
) or 0
|
||||
linked_customers = db.scalar(
|
||||
select(func.count()).select_from(XeroContactLink).where(XeroContactLink.tenant_id == TENANT)
|
||||
) or 0
|
||||
return {
|
||||
"connection": xero_status_snapshot(),
|
||||
"contact_links": {
|
||||
"linked": linked_customers,
|
||||
"total": total_customers,
|
||||
"unlinked": max(total_customers - linked_customers, 0),
|
||||
},
|
||||
"recent_syncs": [
|
||||
{
|
||||
"id": log.id,
|
||||
@@ -999,3 +1032,129 @@ def get_xero_status(
|
||||
for log in recent
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# --- Xero contact mapping ----------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/xero/contacts")
|
||||
def list_available_xero_contacts(
|
||||
session: AuthSession = Depends(require_ordering_admin_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""The Xero contacts available to link a customer against (stub or live)."""
|
||||
contacts, stubbed = list_xero_contacts()
|
||||
return {"contacts": [c.as_dict() for c in contacts], "stubbed": stubbed}
|
||||
|
||||
|
||||
@router.get("/xero/contact-links")
|
||||
def list_xero_contact_links(
|
||||
session: AuthSession = Depends(require_ordering_admin_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Every customer with its current Xero link and a suggested match.
|
||||
|
||||
The suggestion is a best-effort name match against the available contacts so
|
||||
the operator can confirm rather than hunt through a dropdown.
|
||||
"""
|
||||
contacts, _ = list_xero_contacts()
|
||||
by_name = {c.name.strip().lower(): c for c in contacts}
|
||||
|
||||
accounts = db.scalars(select(ClientAccount).order_by(ClientAccount.name)).all()
|
||||
rows = []
|
||||
for account in accounts:
|
||||
link = _xero_link_for(db, account.id)
|
||||
suggestion = None if link else by_name.get(account.name.strip().lower())
|
||||
rows.append(
|
||||
{
|
||||
"customer_id": account.id,
|
||||
"customer_name": account.name,
|
||||
"client_code": account.client_code,
|
||||
"linked": link is not None,
|
||||
"xero_contact_id": link.xero_contact_id if link else None,
|
||||
"xero_contact_name": link.xero_contact_name if link else None,
|
||||
"xero_contact_email": link.xero_contact_email if link else None,
|
||||
"last_synced_at": link.last_synced_at if link else None,
|
||||
"suggested_contact_id": suggestion.contact_id if suggestion else None,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@router.put("/customers/{customer_id}/xero-link")
|
||||
def link_customer_to_xero(
|
||||
customer_id: int,
|
||||
payload: XeroContactLinkUpsert,
|
||||
session: AuthSession = Depends(require_ordering_admin_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = _customer_or_404(db, customer_id)
|
||||
# Default the cached name/email from the known contact list when the caller
|
||||
# only sends an id, so the mapping reads nicely without a live round-trip.
|
||||
name = payload.xero_contact_name
|
||||
email = payload.xero_contact_email
|
||||
if name is None or email is None:
|
||||
contacts, _ = list_xero_contacts()
|
||||
match = next((c for c in contacts if c.contact_id == payload.xero_contact_id), None)
|
||||
if match is not None:
|
||||
name = name or match.name
|
||||
email = email or match.email
|
||||
|
||||
link = _xero_link_for(db, customer_id)
|
||||
if link is None:
|
||||
link = XeroContactLink(
|
||||
tenant_id=TENANT,
|
||||
client_account_id=customer_id,
|
||||
xero_contact_id=payload.xero_contact_id,
|
||||
xero_contact_name=name,
|
||||
xero_contact_email=email,
|
||||
)
|
||||
db.add(link)
|
||||
else:
|
||||
link.xero_contact_id = payload.xero_contact_id
|
||||
link.xero_contact_name = name
|
||||
link.xero_contact_email = email
|
||||
record_audit_event(
|
||||
db,
|
||||
tenant_id=account.tenant_id,
|
||||
client_account_id=account.id,
|
||||
action="xero.contact_linked",
|
||||
target_type="xero_contact_link",
|
||||
target_id=account.id,
|
||||
module_key="ordering",
|
||||
summary=f"{account.name} linked to Xero contact {name or payload.xero_contact_id}.",
|
||||
**_actor(session),
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"linked": True,
|
||||
"xero_contact_id": link.xero_contact_id,
|
||||
"xero_contact_name": link.xero_contact_name,
|
||||
"xero_contact_email": link.xero_contact_email,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/customers/{customer_id}/xero-link", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def unlink_customer_from_xero(
|
||||
customer_id: int,
|
||||
session: AuthSession = Depends(require_ordering_admin_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = _customer_or_404(db, customer_id)
|
||||
link = _xero_link_for(db, customer_id)
|
||||
if link is not None:
|
||||
db.delete(link)
|
||||
record_audit_event(
|
||||
db,
|
||||
tenant_id=account.tenant_id,
|
||||
client_account_id=account.id,
|
||||
action="xero.contact_unlinked",
|
||||
target_type="xero_contact_link",
|
||||
target_id=account.id,
|
||||
module_key="ordering",
|
||||
summary=f"{account.name} unlinked from Xero contact.",
|
||||
**_actor(session),
|
||||
)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Read-only external data API (`/api/v1`).
|
||||
|
||||
A deliberately simple, API-key authenticated surface for Power BI (and any other
|
||||
external reporting tool). It is intentionally separate from the cookie/JWT
|
||||
session model used by the operator frontend: external tools cannot hold a
|
||||
browser session, so they present a single static key instead.
|
||||
|
||||
Authentication: send the key either as an ``X-API-Key`` request header or an
|
||||
``api_key`` query-string parameter (Power BI's Web connector supports both).
|
||||
The key is configured via the ``POWERBI_API_KEY`` environment variable; when it
|
||||
is blank the whole API is disabled and every request returns 503.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security_logging import log_security_event
|
||||
from app.db.session import get_db
|
||||
from app.models.throughput import ProductionThroughput
|
||||
from app.services.throughput_service import serialize_entry
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["public-v1"])
|
||||
|
||||
_API_KEY_HEADER = "X-API-Key"
|
||||
|
||||
|
||||
def require_powerbi_api_key(request: Request) -> str:
|
||||
"""Authorize an external request via the static Power BI API key.
|
||||
|
||||
Returns the tenant the caller may read. Raises 503 when the API is not
|
||||
configured, or 401 when the key is missing/incorrect.
|
||||
"""
|
||||
configured = settings.powerbi_api_key
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="The data API is not configured.",
|
||||
)
|
||||
|
||||
presented = request.headers.get(_API_KEY_HEADER) or request.query_params.get("api_key") or ""
|
||||
# Constant-time comparison so the endpoint does not leak key length/contents
|
||||
# through response timing.
|
||||
if not presented or not secrets.compare_digest(presented, configured):
|
||||
log_security_event("authz.denied", role="powerbi", reason="invalid_api_key")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")
|
||||
|
||||
return settings.powerbi_tenant_id
|
||||
|
||||
|
||||
@router.get("/throughput")
|
||||
def list_throughput(
|
||||
date_from: date | None = Query(default=None, description="Only entries on/after this production date (YYYY-MM-DD)."),
|
||||
date_to: date | None = Query(default=None, description="Only entries on/before this production date (YYYY-MM-DD)."),
|
||||
limit: int = Query(default=5000, ge=1, le=50000),
|
||||
tenant_id: str = Depends(require_powerbi_api_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Flat list of production throughput entries for Power BI.
|
||||
|
||||
One row per packing run, oldest first so incremental refreshes append
|
||||
naturally. Each row carries the same fields the operator UI shows
|
||||
(date, product, bag size, quantity, calculated kg, QA flags, staff, notes).
|
||||
"""
|
||||
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == tenant_id)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
|
||||
stmt = stmt.order_by(ProductionThroughput.production_date.asc(), ProductionThroughput.id.asc()).limit(limit)
|
||||
|
||||
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -10,19 +10,25 @@ from app.api.deps import AuthSession, require_client_module_access
|
||||
from app.db.session import get_db
|
||||
from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||
from app.schemas.throughput import (
|
||||
ThroughputDeleteAllResult,
|
||||
ThroughputEntryCreate,
|
||||
ThroughputEntryRead,
|
||||
ThroughputEntryUpdate,
|
||||
ThroughputImportResult,
|
||||
ThroughputProductCreate,
|
||||
ThroughputProductRead,
|
||||
ThroughputProductUpdate,
|
||||
)
|
||||
from app.services.throughput_service import (
|
||||
calculate_kg,
|
||||
import_entries_from_file,
|
||||
normalise_staff_name,
|
||||
serialize_entry,
|
||||
)
|
||||
|
||||
# Uploaded files larger than this are rejected before we read them into memory.
|
||||
_MAX_IMPORT_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"])
|
||||
|
||||
MODULE_KEY = "operations_throughput"
|
||||
@@ -184,6 +190,48 @@ def create_entry(
|
||||
return serialize_entry(entry)
|
||||
|
||||
|
||||
@router.post("/import", response_model=ThroughputImportResult)
|
||||
def import_entries(
|
||||
file: UploadFile = File(...),
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
content = file.file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="The uploaded file is empty.")
|
||||
if len(content) > _MAX_IMPORT_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File is too large. Keep uploads under 10 MB.")
|
||||
|
||||
try:
|
||||
result = import_entries_from_file(
|
||||
db,
|
||||
filename=file.filename or "upload.csv",
|
||||
content=content,
|
||||
tenant_id=session.tenant_id,
|
||||
created_by=session.email,
|
||||
)
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/entries", response_model=ThroughputDeleteAllResult)
|
||||
def delete_all_entries(
|
||||
# Clearing the log is part of correcting a bad import, so it sits at the same
|
||||
# "edit" level as deleting a single entry. It is scoped to the caller's
|
||||
# tenant, so one client can never wipe another's data.
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
deleted = db.query(ProductionThroughput).filter(
|
||||
ProductionThroughput.tenant_id == session.tenant_id
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return ThroughputDeleteAllResult(entries_deleted=deleted)
|
||||
|
||||
|
||||
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
|
||||
def get_entry(
|
||||
entry_id: int,
|
||||
@@ -233,7 +281,10 @@ def update_entry(
|
||||
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_entry(
|
||||
entry_id: int,
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "manage")),
|
||||
# Correcting a mistaken run is part of day-to-day operating, so deleting an
|
||||
# entry sits at the same "edit" level as adding/editing one. (No throughput
|
||||
# role is granted "manage", so requiring it here would 403 everyone.)
|
||||
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
entry = db.scalar(
|
||||
|
||||
@@ -58,6 +58,12 @@ class Settings:
|
||||
login_rate_limit_window_seconds: int
|
||||
trusted_hosts: tuple[str, ...]
|
||||
docs_enabled: bool
|
||||
# Static API key for the read-only Power BI / external data API (`/api/v1`).
|
||||
# Blank disables the API entirely (every request returns 503).
|
||||
powerbi_api_key: str
|
||||
# Tenant the Power BI API reads from. Defaults to the costing client tenant
|
||||
# (where internal staff store throughput), so a single key serves Irwin.
|
||||
powerbi_tenant_id: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
@@ -98,6 +104,8 @@ class Settings:
|
||||
login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")),
|
||||
trusted_hosts=_parse_csv_env(os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver")),
|
||||
docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"),
|
||||
powerbi_api_key=os.getenv("POWERBI_API_KEY", "").strip(),
|
||||
powerbi_tenant_id=os.getenv("POWERBI_TENANT_ID", os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce")).strip(),
|
||||
)
|
||||
settings._validate()
|
||||
return settings
|
||||
|
||||
@@ -37,6 +37,7 @@ TENANT_TABLES = {
|
||||
"product_cost_freight_inputs": None,
|
||||
"scenarios": None,
|
||||
"costing_results": None,
|
||||
"editor_change_events": None,
|
||||
"process_cost_rules": None,
|
||||
"packaging_cost_rules": None,
|
||||
"freight_cost_rules": None,
|
||||
@@ -65,9 +66,15 @@ class MigrationReport:
|
||||
created_tables: tuple[str, ...] = ()
|
||||
added_columns: tuple[str, ...] = ()
|
||||
synced_tenant_rows: dict[str, int] = field(default_factory=dict)
|
||||
resynced_sequences: tuple[str, ...] = ()
|
||||
|
||||
def has_changes(self) -> bool:
|
||||
return bool(self.created_tables or self.added_columns or self.synced_tenant_rows)
|
||||
return bool(
|
||||
self.created_tables
|
||||
or self.added_columns
|
||||
or self.synced_tenant_rows
|
||||
or self.resynced_sequences
|
||||
)
|
||||
|
||||
def summary(self) -> str:
|
||||
parts: list[str] = []
|
||||
@@ -78,6 +85,8 @@ class MigrationReport:
|
||||
if self.synced_tenant_rows:
|
||||
counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items()))
|
||||
parts.append(f"synced tenant rows: {counts}")
|
||||
if self.resynced_sequences:
|
||||
parts.append(f"resynced sequences: {', '.join(self.resynced_sequences)}")
|
||||
return "; ".join(parts) if parts else "schema already up to date"
|
||||
|
||||
|
||||
@@ -129,6 +138,9 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
|
||||
("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"),
|
||||
("production_throughput_entries", "job_number", "VARCHAR(64)"),
|
||||
("production_throughput_entries", "stock_quantity", "FLOAT"),
|
||||
("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
|
||||
("raw_materials", "category", "VARCHAR(128)"),
|
||||
("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
|
||||
)
|
||||
|
||||
|
||||
@@ -432,7 +444,58 @@ def sync_product_visibility(engine: Engine) -> int:
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def resync_identity_sequences(engine: Engine) -> tuple[str, ...]:
|
||||
"""Realign Postgres identity sequences with each table's current MAX(id).
|
||||
|
||||
After a bulk import that carries original primary keys across (the SQLite →
|
||||
Postgres migration inserts rows with their existing ids), every table's
|
||||
sequence still points at its starting value. The next INSERT then reuses an
|
||||
id that already exists and fails with ``duplicate key value violates unique
|
||||
constraint`` — which is why creating a new mix/ingredient/product saved fine
|
||||
on SQLite but not on production Postgres.
|
||||
|
||||
This advances each ``id`` sequence to MAX(id) so the next INSERT continues
|
||||
cleanly. It is a no-op on SQLite and idempotent on Postgres, so it is safe to
|
||||
run on every startup. A per-table failure is skipped rather than aborting the
|
||||
whole boot.
|
||||
"""
|
||||
if engine.dialect.name != "postgresql":
|
||||
return ()
|
||||
|
||||
resynced: list[str] = []
|
||||
inspector = inspect(engine)
|
||||
with engine.begin() as connection:
|
||||
for table_name in inspector.get_table_names():
|
||||
if not any(column["name"] == "id" for column in inspector.get_columns(table_name)):
|
||||
continue
|
||||
try:
|
||||
sequence = connection.execute(
|
||||
text("SELECT pg_get_serial_sequence(:table, 'id')"),
|
||||
{"table": table_name},
|
||||
).scalar()
|
||||
if not sequence:
|
||||
continue
|
||||
max_id = connection.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
|
||||
if max_id is None:
|
||||
continue
|
||||
connection.execute(
|
||||
text("SELECT setval(:sequence, :value, true)"),
|
||||
{"sequence": sequence, "value": int(max_id)},
|
||||
)
|
||||
resynced.append(table_name)
|
||||
except Exception:
|
||||
# A single problematic table must not block startup; the others
|
||||
# still get realigned.
|
||||
continue
|
||||
return tuple(resynced)
|
||||
|
||||
|
||||
def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
|
||||
created_tables = ensure_metadata_tables(engine, metadata)
|
||||
added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine)
|
||||
return MigrationReport(created_tables=created_tables, added_columns=added_columns)
|
||||
resynced_sequences = resync_identity_sequences(engine)
|
||||
return MigrationReport(
|
||||
created_tables=created_tables,
|
||||
added_columns=added_columns,
|
||||
resynced_sequences=resynced_sequences,
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.api.ordering_admin import router as ordering_admin_router
|
||||
from app.api.powerbi import router as powerbi_router
|
||||
from app.api.product_costing import router as product_costing_router
|
||||
from app.api.products import router as products_router
|
||||
from app.api.public_v1 import router as public_v1_router
|
||||
from app.api.raw_materials import router as raw_materials_router
|
||||
from app.api.scenarios import router as scenarios_router
|
||||
from app.api.throughput import router as throughput_router
|
||||
@@ -116,6 +117,7 @@ def ensure_database_ready() -> MigrationReport:
|
||||
**tenant_sync_report,
|
||||
**({"products_visibility": hidden_product_count} if hidden_product_count else {}),
|
||||
},
|
||||
resynced_sequences=schema_report.resynced_sequences,
|
||||
)
|
||||
logger.info("Database startup checks complete: %s", report.summary())
|
||||
_database_ready = True
|
||||
@@ -209,6 +211,7 @@ app.include_router(throughput_router)
|
||||
app.include_router(ordering_router)
|
||||
app.include_router(ordering_admin_router)
|
||||
app.include_router(powerbi_router)
|
||||
app.include_router(public_v1_router)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -270,6 +273,11 @@ async def enforce_request_limits_and_csrf(request: Request, call_next):
|
||||
"script-src 'self'; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self'; "
|
||||
# PDF previews/printing load a same-origin blob: URL into an iframe.
|
||||
# Without an explicit frame-src/child-src these fall back to default-src
|
||||
# ('self'), which blocks blob: and breaks the in-app print dialog.
|
||||
"frame-src 'self' blob:; "
|
||||
"child-src 'self' blob:; "
|
||||
"frame-ancestors 'self'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
@@ -315,6 +323,7 @@ def root():
|
||||
"scenarios": "/api/scenarios",
|
||||
"operations_throughput": "/api/throughput",
|
||||
"client_access": "/api/client-access",
|
||||
"powerbi_throughput": "/api/v1/throughput",
|
||||
"docs": "/docs",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.models.access import Permission, Role, User, role_permissions
|
||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||
from app.models.change_event import EditorChangeEvent
|
||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
||||
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
@@ -40,6 +41,7 @@ __all__ = [
|
||||
"ClientUser",
|
||||
"ClientUserModulePermission",
|
||||
"CostingResult",
|
||||
"EditorChangeEvent",
|
||||
"CustomerPriceAssignment",
|
||||
"CustomerProductPrice",
|
||||
"CustomerProductVisibility",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class EditorChangeEvent(Base):
|
||||
"""An audit row recording an edit to a mix or an ingredient.
|
||||
|
||||
Written by the editor API whenever a mix or raw material (ingredient) is
|
||||
created or changed, and read back per-entity by the History buttons on the
|
||||
Mix Editor and Ingredients Editor. `changes` holds a list of
|
||||
``{"field", "label", "before", "after"}`` field deltas so the UI can show a
|
||||
readable before/after for each edit.
|
||||
"""
|
||||
|
||||
__tablename__ = "editor_change_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
# "mix" or "ingredient" — the surface the History button lives on.
|
||||
entity_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
entity_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
action: Mapped[str] = mapped_column(String(48))
|
||||
actor_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
actor_email: Mapped[str] = mapped_column(String(255), default="")
|
||||
actor_role: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
changes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
@@ -52,6 +52,8 @@ class MixCalculatorSessionLine(Base):
|
||||
required_kg: Mapped[float] = mapped_column(Float)
|
||||
mix_percentage: Mapped[float] = mapped_column(Float)
|
||||
unit: Mapped[str] = mapped_column(String(64))
|
||||
# Snapshot of the ingredient's rounding setting at save time.
|
||||
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
|
||||
|
||||
@@ -376,6 +376,34 @@ class NotificationSetting(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class XeroContactLink(Base):
|
||||
"""Persistent link between a customer (:class:`ClientAccount`) and a Xero
|
||||
contact.
|
||||
|
||||
Maintained from the Integrations console. Once a customer is linked, order
|
||||
invoices reference the real Xero ``ContactID`` instead of falling back to the
|
||||
client code — which is what lets Xero attach the invoice to the right
|
||||
contact rather than creating a duplicate. One link per customer per tenant.
|
||||
"""
|
||||
|
||||
__tablename__ = "xero_contact_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "client_account_id", name="uq_xero_contact_link_customer"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
xero_contact_id: Mapped[str] = mapped_column(String(128))
|
||||
xero_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
xero_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# When the contact details were last reconciled with Xero (a future live
|
||||
# sync can refresh name/email and stamp this).
|
||||
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, 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)
|
||||
|
||||
|
||||
class XeroSyncLog(Base):
|
||||
__tablename__ = "xero_sync_log"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text
|
||||
from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
@@ -18,6 +18,12 @@ class RawMaterial(Base):
|
||||
unit_of_measure: Mapped[str] = mapped_column(String(64))
|
||||
kg_per_unit: Mapped[float] = mapped_column(Float)
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
# Manually-assigned grouping used to order ingredients in the Mix Calculator
|
||||
# output (e.g. "Grains", "Additives"). Optional; uncategorised rows sort last.
|
||||
category: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Decimal places this ingredient's required-kg is rounded to in the mix
|
||||
# calculator output. Set per-ingredient from the Ingredients Editor.
|
||||
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
@@ -30,12 +32,115 @@ class EditorProductUpdate(BaseModel):
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class EditorMixCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
client_name: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class EditorMixUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
client_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
# Toggling a mix's status fans out to the visibility of every product under it.
|
||||
visible: bool | None = None
|
||||
|
||||
|
||||
class EditorMixRow(BaseModel):
|
||||
id: int
|
||||
tenant_id: str
|
||||
client_name: str
|
||||
name: str
|
||||
# A mix reads as "Active" when at least one of its products is visible.
|
||||
visible: bool
|
||||
product_count: int
|
||||
visible_product_count: int
|
||||
notes: str | None
|
||||
|
||||
|
||||
class EditorMixIngredientRead(BaseModel):
|
||||
id: int
|
||||
raw_material_id: int
|
||||
raw_material_name: str
|
||||
quantity_kg: float
|
||||
notes: str | None
|
||||
|
||||
|
||||
class EditorMixFormulaRead(BaseModel):
|
||||
id: int
|
||||
tenant_id: str
|
||||
client_name: str
|
||||
name: str
|
||||
ingredients: list[EditorMixIngredientRead]
|
||||
total_kg: float
|
||||
|
||||
|
||||
class EditorResolvedMixIngredient(BaseModel):
|
||||
raw_material_id: int
|
||||
raw_material_name: str
|
||||
quantity_kg: float
|
||||
# This row's share of the mix total, matching the Mix Calculator.
|
||||
mix_percentage: float
|
||||
unit: str
|
||||
notes: str | None
|
||||
|
||||
|
||||
class EditorResolvedMixFormula(BaseModel):
|
||||
"""A mix formula resolved the way the Mix Calculator reads it.
|
||||
|
||||
`source` is `product` when the numbers come from a representative product's
|
||||
own formula, or `mix` when they come from the shared mix master fallback.
|
||||
The PUT endpoint writes back to whichever source produced these rows.
|
||||
"""
|
||||
|
||||
id: int
|
||||
tenant_id: str
|
||||
client_name: str
|
||||
name: str
|
||||
source: str
|
||||
product_id: int | None
|
||||
ingredients: list[EditorResolvedMixIngredient]
|
||||
total_kg: float
|
||||
|
||||
|
||||
class EditorMixFormulaRowInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
raw_material_id: int
|
||||
quantity_kg: float = Field(gt=0)
|
||||
notes: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class EditorMixFormulaReplace(BaseModel):
|
||||
"""Full replacement of a mix's formula in one save.
|
||||
|
||||
The frontend keeps kilograms as the canonical value (percentages are an
|
||||
entry aid that resolve back to kg against the total), so the API only needs
|
||||
the resolved kg per row.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
rows: list[EditorMixFormulaRowInput] = Field(min_length=1)
|
||||
|
||||
|
||||
class EditorMixIngredientCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
raw_material_id: int
|
||||
quantity_kg: float = Field(gt=0)
|
||||
notes: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class EditorMixIngredientUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
quantity_kg: float | None = Field(default=None, gt=0)
|
||||
notes: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class EditorProductIngredientCreate(BaseModel):
|
||||
@@ -71,3 +176,73 @@ class EditorProductFormulaRead(BaseModel):
|
||||
mix_name: str
|
||||
ingredients: list[EditorProductIngredientRead]
|
||||
total_kg: float
|
||||
|
||||
|
||||
# --- Ingredients (raw materials) catalogue -----------------------------------
|
||||
|
||||
|
||||
class EditorIngredientRow(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
supplier: str | None
|
||||
unit_of_measure: str
|
||||
kg_per_unit: float
|
||||
status: str
|
||||
# Manual grouping used to order ingredients in the Mix Calculator output.
|
||||
category: str | None
|
||||
# Decimal places this ingredient is rounded to in the mix calculator output.
|
||||
rounding_decimals: int
|
||||
notes: str | None
|
||||
cost_per_kg: float | None
|
||||
# How many product/mix formulas currently reference this ingredient.
|
||||
usage_count: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EditorIngredientCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
supplier: str | None = Field(default=None, max_length=255)
|
||||
unit_of_measure: str = Field(min_length=1, max_length=64)
|
||||
kg_per_unit: float = Field(gt=0)
|
||||
status: str = Field(default="active", max_length=32)
|
||||
rounding_decimals: int = Field(default=2, ge=0, le=6)
|
||||
category: str | None = Field(default=None, max_length=128)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class EditorIngredientUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
supplier: str | None = Field(default=None, max_length=255)
|
||||
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
kg_per_unit: float | None = Field(default=None, gt=0)
|
||||
status: str | None = Field(default=None, max_length=32)
|
||||
rounding_decimals: int | None = Field(default=None, ge=0, le=6)
|
||||
category: str | None = Field(default=None, max_length=128)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
# --- Change history ----------------------------------------------------------
|
||||
|
||||
|
||||
class EditorChangeFieldDelta(BaseModel):
|
||||
field: str
|
||||
label: str
|
||||
before: str | None = None
|
||||
after: str | None = None
|
||||
|
||||
|
||||
class EditorChangeEventRead(BaseModel):
|
||||
id: int
|
||||
entity_type: str
|
||||
entity_id: int
|
||||
action: str
|
||||
actor_name: str
|
||||
actor_email: str
|
||||
actor_role: str | None
|
||||
summary: str
|
||||
changes: list[EditorChangeFieldDelta]
|
||||
created_at: datetime
|
||||
|
||||
@@ -26,6 +26,9 @@ class MixCalculatorSessionLineRead(BaseModel):
|
||||
required_kg: float
|
||||
mix_percentage: float
|
||||
unit: str
|
||||
rounding_decimals: int = 2
|
||||
# Manual ingredient grouping used to order the calculator output.
|
||||
category: str | None = None
|
||||
sort_order: int
|
||||
|
||||
|
||||
|
||||
@@ -238,6 +238,18 @@ class NotificationSettingsUpdate(BaseModel):
|
||||
from_email: str | None = None
|
||||
|
||||
|
||||
# --- Admin: Xero integration -------------------------------------------------
|
||||
|
||||
|
||||
class XeroContactLinkUpsert(BaseModel):
|
||||
"""Link a customer to a Xero contact. ``xero_contact_id`` is the Xero
|
||||
``ContactID`` (or, in stub mode, the deterministic stub id)."""
|
||||
|
||||
xero_contact_id: str = Field(min_length=1, max_length=128)
|
||||
xero_contact_name: str | None = Field(default=None, max_length=255)
|
||||
xero_contact_email: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
# --- Admin: customers & users ------------------------------------------------
|
||||
|
||||
_CUSTOMER_STATUSES = {"active", "disabled"}
|
||||
|
||||
@@ -117,6 +117,17 @@ class ThroughputEntryUpdate(BaseModel):
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class ThroughputImportResult(BaseModel):
|
||||
entries_imported: int
|
||||
entries_skipped: int
|
||||
products_created: int
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ThroughputDeleteAllResult(BaseModel):
|
||||
entries_deleted: int
|
||||
|
||||
|
||||
class ThroughputEntryRead(BaseModel):
|
||||
id: int
|
||||
tenant_id: str
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Recording and reading editor change history.
|
||||
|
||||
The Mix Editor and Ingredients Editor write a row here on every create/edit so
|
||||
each mix and ingredient carries an auditable history (who changed what, when).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.models.change_event import EditorChangeEvent
|
||||
|
||||
# Entity types — these match the History buttons on the two editors.
|
||||
ENTITY_MIX = "mix"
|
||||
ENTITY_INGREDIENT = "ingredient"
|
||||
|
||||
|
||||
def _stringify(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "Yes" if value else "No"
|
||||
if isinstance(value, float):
|
||||
# Trim trailing zeros so 12.50 reads as 12.5 and 12.0 as 12.
|
||||
text = f"{value:.4f}".rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
return str(value)
|
||||
|
||||
|
||||
def diff_fields(before: dict[str, Any], after: dict[str, Any], labels: dict[str, str]) -> list[dict[str, Any]]:
|
||||
"""Build a list of ``{field, label, before, after}`` deltas for changed fields.
|
||||
|
||||
Only keys present in ``labels`` are considered, and only those whose value
|
||||
actually changed are emitted.
|
||||
"""
|
||||
deltas: list[dict[str, Any]] = []
|
||||
for field, label in labels.items():
|
||||
if field not in after:
|
||||
continue
|
||||
old = before.get(field)
|
||||
new = after.get(field)
|
||||
if old == new:
|
||||
continue
|
||||
deltas.append({"field": field, "label": label, "before": _stringify(old), "after": _stringify(new)})
|
||||
return deltas
|
||||
|
||||
|
||||
def record_change(
|
||||
db: Session,
|
||||
*,
|
||||
session: AuthSession,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
action: str,
|
||||
summary: str,
|
||||
changes: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Append a change event. Caller is responsible for committing the session."""
|
||||
db.add(
|
||||
EditorChangeEvent(
|
||||
tenant_id=session.tenant_id or "",
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
actor_name=session.name or session.email or "Unknown",
|
||||
actor_email=session.email or "",
|
||||
actor_role=session.client_role or session.role,
|
||||
summary=summary,
|
||||
changes=changes or [],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def list_changes(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
limit: int = 200,
|
||||
) -> list[EditorChangeEvent]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(EditorChangeEvent)
|
||||
.where(
|
||||
EditorChangeEvent.tenant_id == tenant_id,
|
||||
EditorChangeEvent.entity_type == entity_type,
|
||||
EditorChangeEvent.entity_id == entity_id,
|
||||
)
|
||||
.order_by(EditorChangeEvent.created_at.desc(), EditorChangeEvent.id.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
@@ -319,7 +319,10 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
|
||||
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
|
||||
)
|
||||
pdf.setFont("Helvetica", table_font_size)
|
||||
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg")
|
||||
# Each ingredient carries its own rounding (set in the Ingredients Editor)
|
||||
# so the printed sheet matches the on-screen calculated output.
|
||||
line_decimals = getattr(line, "rounding_decimals", 2)
|
||||
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg, line_decimals)}kg")
|
||||
|
||||
strip_y = table_bottom - 6
|
||||
if note_lines:
|
||||
|
||||
@@ -35,6 +35,33 @@ def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int)
|
||||
)
|
||||
|
||||
|
||||
def _category_sort_key(category: str | None) -> tuple[int, str]:
|
||||
"""Order ingredients by their manual category; uncategorised rows sort last."""
|
||||
cleaned = (category or "").strip()
|
||||
if not cleaned:
|
||||
return (1, "")
|
||||
return (0, cleaned.lower())
|
||||
|
||||
|
||||
def _order_formula_rows(rows: list[dict]) -> list[dict]:
|
||||
"""Sort rows by category (then their original order/name) and renumber.
|
||||
|
||||
Category is the primary key so the Mix Calculator groups ingredients by their
|
||||
manually-assigned category. `sort_order` is reassigned sequentially after the
|
||||
sort so every downstream consumer (lines, PDF) follows the same order.
|
||||
"""
|
||||
rows.sort(
|
||||
key=lambda row: (
|
||||
_category_sort_key(row.get("category")),
|
||||
row.get("sort_order") or 0,
|
||||
row["raw_material_name"].lower(),
|
||||
)
|
||||
)
|
||||
for index, row in enumerate(rows, start=1):
|
||||
row["sort_order"] = index
|
||||
return rows
|
||||
|
||||
|
||||
def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||
if product.ingredients:
|
||||
rows = [
|
||||
@@ -43,6 +70,8 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||
"raw_material_name": ingredient.raw_material.name,
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"unit": ingredient.raw_material.unit_of_measure,
|
||||
"rounding_decimals": ingredient.raw_material.rounding_decimals,
|
||||
"category": ingredient.raw_material.category,
|
||||
"sort_order": ingredient.sort_order,
|
||||
}
|
||||
for ingredient in product.ingredients
|
||||
@@ -55,6 +84,8 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
|
||||
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
|
||||
"sort_order": index,
|
||||
}
|
||||
for index, ingredient in enumerate(product.mix.ingredients, start=1)
|
||||
@@ -62,7 +93,29 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||
else:
|
||||
rows = []
|
||||
|
||||
rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"]))
|
||||
_order_formula_rows(rows)
|
||||
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
|
||||
|
||||
|
||||
def _mix_formula_rows(mix: Mix) -> tuple[list[dict], float]:
|
||||
"""Resolve a mix's own (mix-master) formula rows, category-ordered.
|
||||
|
||||
Used by the Mix Calculator for mixes that have a formula but no representative
|
||||
product yet — the formula lives directly on the mix.
|
||||
"""
|
||||
rows = [
|
||||
{
|
||||
"raw_material_id": ingredient.raw_material_id,
|
||||
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
|
||||
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
|
||||
"sort_order": index,
|
||||
}
|
||||
for index, ingredient in enumerate(mix.ingredients, start=1)
|
||||
]
|
||||
_order_formula_rows(rows)
|
||||
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
|
||||
|
||||
|
||||
@@ -91,31 +144,124 @@ def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]
|
||||
)
|
||||
|
||||
|
||||
def calculate_mix_calculator_preview(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
|
||||
):
|
||||
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
|
||||
product = _load_product_for_calculation(db, tenant_id, int(values["product_id"]))
|
||||
if product is None:
|
||||
raise ValueError("Product not found")
|
||||
if product.client_name != values["client_name"]:
|
||||
raise ValueError("Selected product does not belong to the chosen client")
|
||||
formula_rows, source_total_kg = _resolved_formula_rows(product)
|
||||
if source_total_kg <= 0:
|
||||
raise ValueError("Product has no source kilograms to scale")
|
||||
def resolve_representative_product(db: Session, *, tenant_id: str, mix_id: int) -> Product | None:
|
||||
"""The single product the Mix Calculator surfaces for a given mix.
|
||||
|
||||
The calculator lists one representative product per (client, mix) and reads
|
||||
its formula. The Mix Editor reuses this so it edits exactly what the
|
||||
calculator shows. Preference order mirrors `build_mix_calculator_options`:
|
||||
visible products that already have a product-specific formula, ranked by
|
||||
`_mix_calculator_option_rank`; then any visible product; then any product.
|
||||
"""
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.tenant_id == tenant_id, Product.mix_id == mix_id)
|
||||
.options(
|
||||
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
|
||||
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
|
||||
)
|
||||
).all()
|
||||
if not products:
|
||||
return None
|
||||
with_formula = [product for product in products if product.visible and product.ingredients]
|
||||
pool = with_formula or [product for product in products if product.visible] or list(products)
|
||||
return min(pool, key=_mix_calculator_option_rank)
|
||||
|
||||
|
||||
def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict:
|
||||
"""Resolve a mix's formula the way the calculator does, for the editor.
|
||||
|
||||
Returns the resolved ingredient rows (with each row's share of the total as
|
||||
`mix_percentage`), the total kg, and where the formula lives:
|
||||
`source='product'` (a representative product's own formula) or `source='mix'`
|
||||
(the shared mix master fallback). `product_id` names the product that owns the
|
||||
formula when `source='product'`. The save path writes back to that same source.
|
||||
"""
|
||||
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix.id)
|
||||
if product is not None and product.ingredients:
|
||||
rows, total_kg = _resolved_formula_rows(product)
|
||||
# Carry each ingredient's note through so saving doesn't wipe it.
|
||||
notes_by_raw_material = {
|
||||
ingredient.raw_material_id: ingredient.notes for ingredient in product.ingredients
|
||||
}
|
||||
for row in rows:
|
||||
row["notes"] = notes_by_raw_material.get(row["raw_material_id"])
|
||||
source = "product"
|
||||
product_id = product.id
|
||||
else:
|
||||
# No product-specific formula: the calculator reads the shared mix master,
|
||||
# so the editor shows and edits that.
|
||||
rows = [
|
||||
{
|
||||
"raw_material_id": ingredient.raw_material_id,
|
||||
"raw_material_name": ingredient.raw_material.name
|
||||
if ingredient.raw_material is not None
|
||||
else f"Raw material {ingredient.raw_material_id}",
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||
"sort_order": index,
|
||||
"notes": ingredient.notes,
|
||||
}
|
||||
for index, ingredient in enumerate(
|
||||
sorted(mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else ""),
|
||||
start=1,
|
||||
)
|
||||
]
|
||||
total_kg = round(sum(row["quantity_kg"] for row in rows), 4)
|
||||
source = "mix"
|
||||
product_id = product.id if product is not None else None
|
||||
|
||||
ingredients = [
|
||||
{
|
||||
"raw_material_id": row["raw_material_id"],
|
||||
"raw_material_name": row["raw_material_name"],
|
||||
"quantity_kg": round(row["quantity_kg"], 4),
|
||||
"mix_percentage": round((row["quantity_kg"] / total_kg) * 100, 4) if total_kg > 0 else 0.0,
|
||||
"unit": row["unit"],
|
||||
"notes": row.get("notes"),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return {
|
||||
"id": mix.id,
|
||||
"tenant_id": mix.tenant_id,
|
||||
"client_name": mix.client_name,
|
||||
"name": mix.name,
|
||||
"source": source,
|
||||
"product_id": product_id,
|
||||
"ingredients": ingredients,
|
||||
"total_kg": total_kg,
|
||||
}
|
||||
|
||||
|
||||
def _scale_preview(
|
||||
*,
|
||||
values: dict,
|
||||
formula_rows: list[dict],
|
||||
source_total_kg: float,
|
||||
client_name: str,
|
||||
product_id: int,
|
||||
mix_label: str,
|
||||
mix_id: int,
|
||||
unit_of_measure: str,
|
||||
) -> dict:
|
||||
"""Scale a resolved formula to the requested batch size and shape the preview.
|
||||
|
||||
Shared by the product-backed path and the formula-only mix path; only the
|
||||
inputs (where the formula and unit come from) differ.
|
||||
"""
|
||||
batch_size_kg = float(values["batch_size_kg"])
|
||||
scale_factor = batch_size_kg / source_total_kg
|
||||
unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure)
|
||||
unit_size_kg = extract_unit_quantity_kg(unit_of_measure)
|
||||
total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0
|
||||
|
||||
warnings: list[str] = []
|
||||
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure)
|
||||
if bag_warning:
|
||||
warnings.append(bag_warning)
|
||||
# A bag warning only makes sense when the unit resolves to a bag size; a
|
||||
# formula-only mix sells in bulk kg, so there's nothing to round to whole bags.
|
||||
if unit_size_kg > 0:
|
||||
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, unit_of_measure)
|
||||
if bag_warning:
|
||||
warnings.append(bag_warning)
|
||||
|
||||
lines = []
|
||||
for index, ingredient in enumerate(formula_rows, start=1):
|
||||
@@ -128,24 +274,25 @@ def calculate_mix_calculator_preview(
|
||||
"required_kg": required_kg,
|
||||
"mix_percentage": mix_percentage,
|
||||
"unit": ingredient["unit"],
|
||||
"rounding_decimals": ingredient.get("rounding_decimals", 2),
|
||||
"category": ingredient.get("category"),
|
||||
"sort_order": ingredient["sort_order"] or index,
|
||||
}
|
||||
)
|
||||
|
||||
mix_label = _mix_calculator_label(product)
|
||||
return {
|
||||
"client_name": product.client_name,
|
||||
"product_id": product.id,
|
||||
"client_name": client_name,
|
||||
"product_id": product_id,
|
||||
# The source workbook labels this as Product, but for the calculator
|
||||
# it is the mix/formula being produced.
|
||||
"product_name": mix_label,
|
||||
"mix_id": product.mix_id,
|
||||
"mix_id": mix_id,
|
||||
"mix_name": mix_label,
|
||||
"mix_date": values["mix_date"],
|
||||
"batch_size_kg": round(batch_size_kg, 4),
|
||||
"total_bags": total_bags,
|
||||
"total_kg": round(batch_size_kg, 4),
|
||||
"product_unit_of_measure": product.unit_of_measure,
|
||||
"product_unit_of_measure": unit_of_measure,
|
||||
"product_unit_size_kg": round(unit_size_kg, 4),
|
||||
"prepared_by_name": values["prepared_by_name"],
|
||||
"status": values.get("status") or "saved",
|
||||
@@ -155,6 +302,70 @@ def calculate_mix_calculator_preview(
|
||||
}
|
||||
|
||||
|
||||
def _calculate_mix_only_preview(db: Session, *, tenant_id: str, mix_id: int, values: dict) -> dict:
|
||||
"""Preview for a mix that has a formula but no representative product.
|
||||
|
||||
The Mix Calculator surfaces these via a negative `product_id` sentinel
|
||||
(`-mix_id`); the formula is read straight off the mix master and there's no
|
||||
product unit, so output is bulk kg with no bag split.
|
||||
"""
|
||||
mix = db.scalar(
|
||||
select(Mix)
|
||||
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
|
||||
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
|
||||
)
|
||||
if mix is None:
|
||||
raise ValueError("Mix not found")
|
||||
if mix.client_name != values["client_name"]:
|
||||
raise ValueError("Selected mix does not belong to the chosen client")
|
||||
formula_rows, source_total_kg = _mix_formula_rows(mix)
|
||||
if source_total_kg <= 0:
|
||||
raise ValueError("Mix has no formula to scale")
|
||||
return _scale_preview(
|
||||
values=values,
|
||||
formula_rows=formula_rows,
|
||||
source_total_kg=source_total_kg,
|
||||
client_name=mix.client_name,
|
||||
product_id=-mix.id,
|
||||
mix_label=mix.name,
|
||||
mix_id=mix.id,
|
||||
unit_of_measure="kg",
|
||||
)
|
||||
|
||||
|
||||
def calculate_mix_calculator_preview(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
|
||||
):
|
||||
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
|
||||
product_id = int(values["product_id"])
|
||||
# Negative ids are the sentinel for a formula-only mix (no product yet).
|
||||
if product_id < 0:
|
||||
return _calculate_mix_only_preview(db, tenant_id=tenant_id, mix_id=-product_id, values=values)
|
||||
|
||||
product = _load_product_for_calculation(db, tenant_id, product_id)
|
||||
if product is None:
|
||||
raise ValueError("Product not found")
|
||||
if product.client_name != values["client_name"]:
|
||||
raise ValueError("Selected product does not belong to the chosen client")
|
||||
formula_rows, source_total_kg = _resolved_formula_rows(product)
|
||||
if source_total_kg <= 0:
|
||||
raise ValueError("Product has no source kilograms to scale")
|
||||
|
||||
return _scale_preview(
|
||||
values=values,
|
||||
formula_rows=formula_rows,
|
||||
source_total_kg=source_total_kg,
|
||||
client_name=product.client_name,
|
||||
product_id=product.id,
|
||||
mix_label=_mix_calculator_label(product),
|
||||
mix_id=product.mix_id,
|
||||
unit_of_measure=product.unit_of_measure,
|
||||
)
|
||||
|
||||
|
||||
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||
# Prefer product-specific formulas where present; fall back to the shared
|
||||
# mix master for legacy rows that have not been migrated yet.
|
||||
@@ -203,7 +414,6 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||
key=lambda product: (product.client_name, _mix_calculator_label(product), product.id),
|
||||
)
|
||||
|
||||
clients = sorted({product.client_name for product in products})
|
||||
product_rows = [
|
||||
{
|
||||
"product_id": product.id,
|
||||
@@ -218,6 +428,44 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||
for product in products
|
||||
]
|
||||
|
||||
# Surface mixes that have a formula but no product at all yet (e.g. a freshly
|
||||
# created mix). They're selected via a negative `product_id` sentinel (-mix_id)
|
||||
# and calculated straight off the mix master — bulk kg, no bag split. A mix
|
||||
# whose only product is hidden is intentionally excluded (it HAS a product),
|
||||
# so check every product, not just the visible representatives.
|
||||
covered_mix_ids = set(
|
||||
db.scalars(
|
||||
select(Product.mix_id).where(Product.tenant_id == tenant_id).distinct()
|
||||
).all()
|
||||
)
|
||||
formula_only_mix_ids = [
|
||||
mix_id for mix_id, total in mix_totals.items() if total > 0 and mix_id not in covered_mix_ids
|
||||
]
|
||||
if formula_only_mix_ids:
|
||||
formula_only_mixes = db.scalars(
|
||||
select(Mix).where(
|
||||
Mix.tenant_id == tenant_id,
|
||||
Mix.id.in_(formula_only_mix_ids),
|
||||
Mix.status == "active",
|
||||
)
|
||||
).all()
|
||||
product_rows.extend(
|
||||
{
|
||||
"product_id": -mix.id,
|
||||
"client_name": mix.client_name,
|
||||
"product_name": mix.name,
|
||||
"mix_id": mix.id,
|
||||
"mix_name": mix.name,
|
||||
"unit_of_measure": "kg",
|
||||
"unit_size_kg": 0.0,
|
||||
"mix_total_kg": mix_totals.get(mix.id, 0.0),
|
||||
}
|
||||
for mix in formula_only_mixes
|
||||
)
|
||||
|
||||
product_rows.sort(key=lambda row: (row["client_name"], row["product_name"], row["product_id"]))
|
||||
clients = sorted({row["client_name"] for row in product_rows})
|
||||
|
||||
return {"clients": clients, "products": product_rows}
|
||||
|
||||
|
||||
@@ -260,6 +508,7 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_
|
||||
"required_kg": round(line.required_kg, 4),
|
||||
"mix_percentage": round(line.mix_percentage, 4),
|
||||
"unit": line.unit,
|
||||
"rounding_decimals": line.rounding_decimals,
|
||||
"sort_order": line.sort_order,
|
||||
}
|
||||
for line in session_record.lines
|
||||
@@ -302,6 +551,10 @@ def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str:
|
||||
|
||||
|
||||
def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict:
|
||||
if payload.product_id < 0:
|
||||
# Sessions reference a real product (FK). A formula-only mix has none yet —
|
||||
# it can still be previewed and printed, just not saved as a session.
|
||||
raise ValueError("Add a product to this mix before saving a calculator session.")
|
||||
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload)
|
||||
session_record = MixCalculatorSession(
|
||||
tenant_id=auth_session.tenant_id or "default",
|
||||
@@ -331,6 +584,7 @@ def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, pay
|
||||
required_kg=line["required_kg"],
|
||||
mix_percentage=line["mix_percentage"],
|
||||
unit=line["unit"],
|
||||
rounding_decimals=line.get("rounding_decimals", 2),
|
||||
sort_order=line["sort_order"],
|
||||
)
|
||||
for line in preview["lines"]
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
@@ -99,6 +102,29 @@ def _coerce_bool(value: object) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _coerce_import_bool(value: object, *, default: bool = False) -> bool:
|
||||
"""Conservative boolean parsing for ad-hoc imports.
|
||||
|
||||
Uploaded CSV/XLSX rows often leave destination columns blank, or use text
|
||||
like "stock" / "order" elsewhere in the row. Those should not silently
|
||||
become True. Only explicit truthy markers opt in.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return default
|
||||
if text in {"yes", "y", "true", "1", "pass", "ok", "x", "checked"}:
|
||||
return True
|
||||
if text in {"no", "n", "false", "0", "fail"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_float(value: object) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
@@ -124,7 +150,17 @@ def _coerce_text(value: object) -> str | None:
|
||||
return text
|
||||
|
||||
|
||||
def _coerce_date(value: object) -> date | None:
|
||||
# Default slash-date preference. The app is Australian, so an ambiguous
|
||||
# "x/y/z" is read day-first unless a column is detected as month-first.
|
||||
_DAY_FIRST_FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y")
|
||||
_MONTH_FIRST_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y")
|
||||
|
||||
_SLASH_DATE_RE = re.compile(r"^\s*(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})\s*$")
|
||||
|
||||
|
||||
def _coerce_date(
|
||||
value: object, formats: tuple[str, ...] = _DAY_FIRST_FORMATS
|
||||
) -> date | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
@@ -134,7 +170,7 @@ def _coerce_date(value: object) -> date | None:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
@@ -142,6 +178,32 @@ def _coerce_date(value: object) -> date | None:
|
||||
return None
|
||||
|
||||
|
||||
def _detect_slash_date_formats(values: Iterable[object]) -> tuple[str, ...]:
|
||||
"""Inspect every slash/dash date in a column and decide whether the file is
|
||||
day-first (D/M/Y) or month-first (M/D/Y), so all rows parse consistently.
|
||||
|
||||
A first component > 12 proves day-first; a second component > 12 proves
|
||||
month-first. If only month-first evidence exists we switch to M/D/Y;
|
||||
otherwise we keep the Australian day-first default.
|
||||
"""
|
||||
day_first = False
|
||||
month_first = False
|
||||
for value in values:
|
||||
if value is None or isinstance(value, (datetime, date)):
|
||||
continue
|
||||
match = _SLASH_DATE_RE.match(str(value))
|
||||
if not match:
|
||||
continue
|
||||
first, second = int(match.group(1)), int(match.group(2))
|
||||
if first > 12:
|
||||
day_first = True
|
||||
elif second > 12:
|
||||
month_first = True
|
||||
if month_first and not day_first:
|
||||
return _MONTH_FIRST_FORMATS
|
||||
return _DAY_FIRST_FORMATS
|
||||
|
||||
|
||||
def _infer_bulka_default(name: str, bag_size: float | None) -> bool:
|
||||
lowered = name.lower()
|
||||
if "bulka" in lowered:
|
||||
@@ -369,3 +431,314 @@ def resolve_workbook_path() -> Path | None:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
# ── Ad-hoc CSV / spreadsheet upload import ──────────────────────────────────
|
||||
# Lets an operator upload their own CSV or .xlsx of packing runs (from Settings
|
||||
# → Import) and have every row saved as a throughput entry. Unlike the bundled
|
||||
# workbook seed above, this is column-header driven so the file can be a simple
|
||||
# hand-built sheet rather than the exact "Operations Throughput.xlsx" layout.
|
||||
|
||||
# Maps the column headers we accept (normalised: lower-cased, spaces/dashes →
|
||||
# single spaces) onto the canonical field used internally. Several aliases per
|
||||
# field so a human-built sheet "just works".
|
||||
_HEADER_ALIASES: dict[str, str] = {
|
||||
"date": "date",
|
||||
"production date": "date",
|
||||
"production_date": "date",
|
||||
"product": "product",
|
||||
"product name": "product",
|
||||
"product_name": "product",
|
||||
"product name snapshot": "product",
|
||||
"name": "product",
|
||||
"item id": "item_id",
|
||||
"item_id": "item_id",
|
||||
"itemid": "item_id",
|
||||
"sku": "item_id",
|
||||
"quantity": "quantity",
|
||||
"qty": "quantity",
|
||||
"packed": "quantity",
|
||||
"quantity packed": "quantity",
|
||||
"amount": "quantity",
|
||||
"quantity type": "quantity_type",
|
||||
"type": "quantity_type",
|
||||
"unit": "quantity_type",
|
||||
"packed as": "quantity_type",
|
||||
"bag size": "bag_size",
|
||||
"bag_size": "bag_size",
|
||||
"kg per bag": "bag_size",
|
||||
"kg/bag": "bag_size",
|
||||
"bagsize": "bag_size",
|
||||
"staff": "staff_name",
|
||||
"staff name": "staff_name",
|
||||
"packed by": "staff_name",
|
||||
"operator": "staff_name",
|
||||
"for order": "for_order",
|
||||
"order": "for_order",
|
||||
"for stock": "for_stock",
|
||||
"stock": "for_stock",
|
||||
"job number": "job_number",
|
||||
"job": "job_number",
|
||||
"job no": "job_number",
|
||||
"order number": "job_number",
|
||||
"stock quantity": "stock_quantity",
|
||||
"stock qty": "stock_quantity",
|
||||
"sample box no": "sample_box_no",
|
||||
"sample box": "sample_box_no",
|
||||
"scales checked": "scales_checked",
|
||||
"scales": "scales_checked",
|
||||
"label correct": "label_correct",
|
||||
"label": "label_correct",
|
||||
"bag sealed": "bag_sealed",
|
||||
"sealed": "bag_sealed",
|
||||
"pallet good condition": "pallet_good_condition",
|
||||
"pallet": "pallet_good_condition",
|
||||
"notes": "notes",
|
||||
"note": "notes",
|
||||
"comment": "notes",
|
||||
"comments": "notes",
|
||||
}
|
||||
|
||||
# How many row-level errors we collect before truncating, to keep the response
|
||||
# (and the toast) sane on a badly-formed file.
|
||||
_MAX_REPORTED_ERRORS = 50
|
||||
|
||||
|
||||
def _normalise_header(raw: object) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
key = " ".join(str(raw).strip().lower().replace("-", " ").replace("_", " ").split())
|
||||
if not key:
|
||||
return None
|
||||
if key in _HEADER_ALIASES:
|
||||
return _HEADER_ALIASES[key]
|
||||
# Test weights: "test weight 1".."test weight 5" (and "tw1" style).
|
||||
for n in range(1, 6):
|
||||
if key in {f"test weight {n}", f"tw{n}", f"test {n}"}:
|
||||
return f"test_weight_{n}"
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_quantity_type(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
if text in {"bag", "bags", "b"}:
|
||||
return "bags"
|
||||
if text in {"kg", "kgs", "kilogram", "kilograms", "bulka", "bulk"}:
|
||||
return "kg"
|
||||
return None
|
||||
|
||||
|
||||
def _read_tabular_file(filename: str, content: bytes) -> tuple[list[str | None], list[tuple]]:
|
||||
"""Return (headers, data_rows). Detects CSV vs .xlsx by extension/content."""
|
||||
lowered = (filename or "").lower()
|
||||
is_excel = lowered.endswith((".xlsx", ".xlsm", ".xls"))
|
||||
|
||||
if is_excel:
|
||||
workbook = load_workbook(io.BytesIO(content), data_only=True, read_only=True)
|
||||
ws = workbook.active
|
||||
rows = [tuple(r) for r in ws.iter_rows(values_only=True)]
|
||||
workbook.close()
|
||||
else:
|
||||
text = None
|
||||
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
|
||||
try:
|
||||
text = content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if text is None:
|
||||
raise ValueError("Could not decode the file as text. Save it as UTF-8 CSV or .xlsx.")
|
||||
# Sniff the delimiter (comma/semicolon/tab) but fall back to comma.
|
||||
sample = text[:4096]
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
|
||||
except csv.Error:
|
||||
dialect = csv.excel
|
||||
rows = [tuple(r) for r in csv.reader(io.StringIO(text), dialect)]
|
||||
|
||||
# Find the first row that has at least one recognised header; treat it as
|
||||
# the header row and everything after as data.
|
||||
for index, row in enumerate(rows):
|
||||
if any(_normalise_header(cell) is not None for cell in row):
|
||||
return list(row), rows[index + 1 :]
|
||||
|
||||
return [], []
|
||||
|
||||
|
||||
def import_entries_from_file(
|
||||
db: Session,
|
||||
*,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
tenant_id: str,
|
||||
created_by: str | None,
|
||||
) -> dict:
|
||||
"""Parse an uploaded CSV/spreadsheet and persist each row as a throughput
|
||||
entry. Products are matched by item_id then name, and auto-created when not
|
||||
found so every entry stays linked. Returns a summary with row-level errors.
|
||||
"""
|
||||
headers, data_rows = _read_tabular_file(filename, content)
|
||||
if not headers:
|
||||
raise ValueError(
|
||||
"No recognised columns found. The file needs a header row with at "
|
||||
"least Date, Product and Quantity columns."
|
||||
)
|
||||
|
||||
# Map canonical field name → column index. First occurrence wins.
|
||||
field_index: dict[str, int] = {}
|
||||
for col, raw in enumerate(headers):
|
||||
field = _normalise_header(raw)
|
||||
if field and field not in field_index:
|
||||
field_index[field] = col
|
||||
|
||||
for required in ("date", "product", "quantity"):
|
||||
if required not in field_index:
|
||||
raise ValueError(
|
||||
f"Missing required '{required}' column. Required columns are "
|
||||
"Date, Product and Quantity."
|
||||
)
|
||||
|
||||
def cell(row: tuple, field: str) -> object:
|
||||
idx = field_index.get(field)
|
||||
if idx is None or idx >= len(row):
|
||||
return None
|
||||
return row[idx]
|
||||
|
||||
# Decide the slash-date order once for the whole file so ambiguous values
|
||||
# like "12/9/2025" follow the same convention as the unambiguous ones.
|
||||
date_formats = _detect_slash_date_formats(cell(row, "date") for row in data_rows)
|
||||
|
||||
# Index existing products for matching (by item_id and by lower-cased name).
|
||||
by_item: dict[str, ThroughputProduct] = {}
|
||||
by_name: dict[str, ThroughputProduct] = {}
|
||||
for product in db.scalars(
|
||||
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
|
||||
).all():
|
||||
if product.item_id:
|
||||
by_item[str(product.item_id)] = product
|
||||
by_name[product.name.lower()] = product
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
products_created = 0
|
||||
errors: list[str] = []
|
||||
|
||||
def note_error(message: str) -> None:
|
||||
if len(errors) < _MAX_REPORTED_ERRORS:
|
||||
errors.append(message)
|
||||
|
||||
for offset, row in enumerate(data_rows):
|
||||
# Sheet/file row number for human-friendly error messages (header = 1).
|
||||
line_no = offset + 2
|
||||
if not row or all(value is None or str(value).strip() == "" for value in row):
|
||||
continue
|
||||
|
||||
production_date = _coerce_date(cell(row, "date"), date_formats)
|
||||
product_name = _coerce_text(cell(row, "product"))
|
||||
quantity = _coerce_float(cell(row, "quantity"))
|
||||
|
||||
if production_date is None:
|
||||
skipped += 1
|
||||
note_error(f"Row {line_no}: missing or invalid date.")
|
||||
continue
|
||||
if not product_name:
|
||||
skipped += 1
|
||||
note_error(f"Row {line_no}: missing product name.")
|
||||
continue
|
||||
if quantity is None or quantity < 0:
|
||||
skipped += 1
|
||||
note_error(f"Row {line_no}: missing or invalid quantity.")
|
||||
continue
|
||||
|
||||
bag_size = _coerce_float(cell(row, "bag_size"))
|
||||
|
||||
quantity_type = _coerce_quantity_type(cell(row, "quantity_type"))
|
||||
if quantity_type is None:
|
||||
# Infer: bulka-style rows have a blank or very large bag size.
|
||||
if bag_size is None or bag_size >= _BULKA_BAG_SIZE_THRESHOLD or "bulka" in product_name.lower():
|
||||
quantity_type = "kg"
|
||||
else:
|
||||
quantity_type = "bags"
|
||||
|
||||
if quantity_type == "bags" and (bag_size is None or bag_size <= 0):
|
||||
skipped += 1
|
||||
note_error(f"Row {line_no}: bag size is required when packed as bags.")
|
||||
continue
|
||||
|
||||
item_id_raw = cell(row, "item_id")
|
||||
item_id = None
|
||||
if item_id_raw is not None:
|
||||
if isinstance(item_id_raw, float) and item_id_raw.is_integer():
|
||||
item_id = str(int(item_id_raw))
|
||||
else:
|
||||
item_id = _coerce_text(item_id_raw)
|
||||
|
||||
product = (by_item.get(item_id) if item_id else None) or by_name.get(product_name.lower())
|
||||
if product is None:
|
||||
product = ThroughputProduct(
|
||||
tenant_id=tenant_id,
|
||||
item_id=item_id,
|
||||
name=product_name,
|
||||
default_bag_size=bag_size,
|
||||
is_bulka_default=_infer_bulka_default(product_name, bag_size),
|
||||
active=True,
|
||||
notes="Auto-created during throughput import",
|
||||
)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
products_created += 1
|
||||
if item_id:
|
||||
by_item[item_id] = product
|
||||
by_name[product_name.lower()] = product
|
||||
|
||||
for_order = _coerce_import_bool(cell(row, "for_order")) if field_index.get("for_order") is not None else False
|
||||
for_stock = _coerce_import_bool(cell(row, "for_stock")) if field_index.get("for_stock") is not None else False
|
||||
stock_quantity = _coerce_float(cell(row, "stock_quantity")) if for_stock else None
|
||||
|
||||
calculated = calculate_kg(quantity, quantity_type, bag_size)
|
||||
entry = ProductionThroughput(
|
||||
tenant_id=tenant_id,
|
||||
production_date=production_date,
|
||||
product_id=product.id,
|
||||
product_name_snapshot=product_name,
|
||||
bag_size=bag_size,
|
||||
scales_checked=_coerce_bool(cell(row, "scales_checked")),
|
||||
label_correct=_coerce_bool(cell(row, "label_correct")),
|
||||
bag_sealed=_coerce_bool(cell(row, "bag_sealed")),
|
||||
pallet_good_condition=_coerce_bool(cell(row, "pallet_good_condition")),
|
||||
for_order=for_order,
|
||||
for_stock=for_stock,
|
||||
job_number=_coerce_text(cell(row, "job_number")) if for_order else None,
|
||||
stock_quantity=stock_quantity,
|
||||
sample_box_no=_coerce_text(cell(row, "sample_box_no")),
|
||||
test_weight_1=_coerce_float(cell(row, "test_weight_1")),
|
||||
test_weight_2=_coerce_float(cell(row, "test_weight_2")),
|
||||
test_weight_3=_coerce_float(cell(row, "test_weight_3")),
|
||||
test_weight_4=_coerce_float(cell(row, "test_weight_4")),
|
||||
test_weight_5=_coerce_float(cell(row, "test_weight_5")),
|
||||
quantity=quantity,
|
||||
quantity_type=quantity_type,
|
||||
calculated_kg=calculated,
|
||||
staff_name=normalise_staff_name(cell(row, "staff_name")),
|
||||
notes=_coerce_text(cell(row, "notes")),
|
||||
created_by=created_by or "csv-import",
|
||||
)
|
||||
db.add(entry)
|
||||
imported += 1
|
||||
|
||||
if imported == 0 and products_created == 0:
|
||||
# Nothing landed — don't leave a half-open transaction.
|
||||
db.rollback()
|
||||
else:
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"entries_imported": imported,
|
||||
"entries_skipped": skipped,
|
||||
"products_created": products_created,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.client_access import ClientAccount
|
||||
from app.models.ordering import Order
|
||||
from app.models.ordering import Order, XeroContactLink
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,11 +57,75 @@ class XeroSubmissionResult:
|
||||
line_items: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def map_customer_to_contact(customer: ClientAccount) -> dict:
|
||||
"""Map a customer account onto a Xero contact payload."""
|
||||
@dataclass
|
||||
class XeroContact:
|
||||
"""A Xero contact available to link a customer against."""
|
||||
|
||||
contact_id: str
|
||||
name: str
|
||||
email: str | None = None
|
||||
status: str = "ACTIVE"
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"contact_id": self.contact_id,
|
||||
"name": self.name,
|
||||
"email": self.email,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
# Deterministic sample contacts used while running in stub mode (no Xero
|
||||
# credentials). They stand in for "what's in Xero" so the customer→contact
|
||||
# mapping UI is usable before the live API is wired. Ids mimic Xero GUIDs.
|
||||
_STUB_CONTACTS: tuple[XeroContact, ...] = (
|
||||
XeroContact("STUB-CON-0001", "Hunter Premium Produce", "accounts@hunterpremium.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0002", "Mayreef Pty Ltd", "ap@mayreef.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0003", "Ian McKay Stock Feeds", "ian@mckayfeeds.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0004", "Peckish Bird Foods", "orders@peckish.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0005", "Hay & Straw Co", "info@hayandstraw.example", "ACTIVE"),
|
||||
XeroContact("STUB-CON-0006", "PHF Horse Mixes", "accounts@phfhorse.example", "ACTIVE"),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_contacts_from_api(config: XeroConfig) -> list[XeroContact]:
|
||||
"""Live contact fetch. Stubbed until credentials/endpoints are wired.
|
||||
|
||||
TODO (go-live): GET ``{config.base_url}/Contacts`` with the
|
||||
``Xero-tenant-id`` header, page through ``Contacts[]`` and map each onto a
|
||||
:class:`XeroContact` (``ContactID``/``Name``/``EmailAddress``/``ContactStatus``).
|
||||
"""
|
||||
raise NotImplementedError("Live Xero contact fetch is not implemented yet.")
|
||||
|
||||
|
||||
def list_xero_contacts(config: XeroConfig | None = None) -> tuple[list[XeroContact], bool]:
|
||||
"""Return the Xero contacts available for linking and whether they're stubbed.
|
||||
|
||||
Never raises — on a live-mode error it returns an empty list so the mapping
|
||||
console still renders.
|
||||
"""
|
||||
config = config or XeroConfig.from_env()
|
||||
if not config.configured:
|
||||
return list(_STUB_CONTACTS), True
|
||||
try:
|
||||
return _fetch_contacts_from_api(config), False
|
||||
except Exception: # pragma: no cover - defensive: never break the request path
|
||||
return [], False
|
||||
|
||||
|
||||
def map_customer_to_contact(customer: ClientAccount, link: XeroContactLink | None = None) -> dict:
|
||||
"""Map a customer account onto a Xero contact payload.
|
||||
|
||||
When the customer has been linked to a Xero contact we send the real
|
||||
``ContactID`` so Xero attaches the invoice to the existing contact. Without a
|
||||
link we fall back to keying on the client code (Xero will match-or-create).
|
||||
"""
|
||||
if link is not None and link.xero_contact_id:
|
||||
return {
|
||||
"ContactID": link.xero_contact_id,
|
||||
"Name": link.xero_contact_name or customer.name,
|
||||
}
|
||||
return {
|
||||
# TODO: persist and reuse a real Xero ContactID once the contact has
|
||||
# been created/matched in Xero. For now we key on the client code.
|
||||
"ContactNumber": customer.client_code,
|
||||
"Name": customer.name,
|
||||
}
|
||||
@@ -76,7 +140,9 @@ def map_product_to_item_code(product_sku: str) -> str:
|
||||
return product_sku
|
||||
|
||||
|
||||
def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
|
||||
def build_invoice_payload(
|
||||
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
||||
) -> dict:
|
||||
"""Build the Xero draft-invoice payload for a confirmed order."""
|
||||
line_items = []
|
||||
for line in order.lines:
|
||||
@@ -98,7 +164,7 @@ def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
|
||||
return {
|
||||
"Type": "ACCREC",
|
||||
"Status": "DRAFT",
|
||||
"Contact": map_customer_to_contact(customer),
|
||||
"Contact": map_customer_to_contact(customer, link),
|
||||
"Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
|
||||
"LineAmountTypes": "Exclusive",
|
||||
"LineItems": line_items,
|
||||
@@ -127,14 +193,17 @@ def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResu
|
||||
)
|
||||
|
||||
|
||||
def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult:
|
||||
def submit_order_to_xero(
|
||||
order: Order, customer: ClientAccount, link: XeroContactLink | None = None
|
||||
) -> XeroSubmissionResult:
|
||||
"""Submit a confirmed order to Xero, or stub it when unconfigured.
|
||||
|
||||
Never raises — failures are returned as ``status="failed"`` results so the
|
||||
order lifecycle can record the attempt and continue.
|
||||
Pass ``link`` to invoice against the customer's mapped Xero contact. Never
|
||||
raises — failures are returned as ``status="failed"`` results so the order
|
||||
lifecycle can record the attempt and continue.
|
||||
"""
|
||||
config = XeroConfig.from_env()
|
||||
payload = build_invoice_payload(order, customer)
|
||||
payload = build_invoice_payload(order, customer, link)
|
||||
summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
|
||||
|
||||
if not config.configured:
|
||||
|
||||
@@ -3,12 +3,13 @@ requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-entry-app-backend"
|
||||
version = "0.1.14"
|
||||
description = "Costing platform MVP backend"
|
||||
name = "hunter-backend"
|
||||
version = "0.1.36"
|
||||
description = "Costing platform MVP backend (API for Hunter)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<1.0",
|
||||
"python-multipart>=0.0.9,<1.0",
|
||||
"openpyxl>=3.1,<4.0",
|
||||
"rich>=13.9,<15.0",
|
||||
"uvicorn[standard]>=0.30,<1.0",
|
||||
|
||||
@@ -347,3 +347,207 @@ def test_internal_user_can_change_own_password(access_app_and_db):
|
||||
json={"email": admin.email, "password": "new-personal-password"},
|
||||
)
|
||||
assert new_login.status_code == 200
|
||||
|
||||
|
||||
# --- Admin user management --------------------------------------------------
|
||||
|
||||
|
||||
def _admin_headers(db: Session) -> dict[str, str]:
|
||||
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||
return {"Authorization": f"Bearer {_token_for(admin)}"}
|
||||
|
||||
|
||||
def test_manage_users_create_update_password_delete(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
full_access_role = db.query(Role).filter_by(name="Full Access").one()
|
||||
|
||||
created = client.post(
|
||||
"/api/access/users",
|
||||
json={"email": "new.user@hunterstockfeeds.com", "name": "New User", "role_id": full_access_role.id},
|
||||
headers=headers,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["email"] == "new.user@hunterstockfeeds.com"
|
||||
assert body["role"] == "Full Access"
|
||||
assert body["is_protected"] is False
|
||||
user_id = body["id"]
|
||||
|
||||
operations_role = db.query(Role).filter_by(name="Operations").one()
|
||||
updated = client.patch(
|
||||
f"/api/access/users/{user_id}",
|
||||
json={"name": "Renamed", "role_id": operations_role.id, "is_active": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["name"] == "Renamed"
|
||||
assert updated.json()["role"] == "Operations"
|
||||
assert updated.json()["is_active"] is False
|
||||
|
||||
pw = client.post(
|
||||
f"/api/access/users/{user_id}/password",
|
||||
json={"new_password": "brand-new-pass"},
|
||||
headers=headers,
|
||||
)
|
||||
assert pw.status_code == 200
|
||||
db.expire_all()
|
||||
target = db.query(User).filter_by(id=user_id).one()
|
||||
assert verify_password("brand-new-pass", target.password_hash)
|
||||
|
||||
deleted = client.delete(f"/api/access/users/{user_id}", headers=headers)
|
||||
assert deleted.status_code == 204
|
||||
assert db.query(User).filter_by(id=user_id).one_or_none() is None
|
||||
|
||||
|
||||
def test_create_user_rejects_duplicate_email(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
|
||||
response = client.post(
|
||||
"/api/access/users",
|
||||
json={"email": "admin@hunterstockfeeds.com", "name": "Dup"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_manage_users_requires_permission(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
||||
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
|
||||
|
||||
response = client.post(
|
||||
"/api/access/users",
|
||||
json={"email": "x@hunterstockfeeds.com", "name": "X"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_cannot_deactivate_or_delete_self(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||
headers = {"Authorization": f"Bearer {_token_for(admin)}"}
|
||||
|
||||
deactivate = client.patch(
|
||||
f"/api/access/users/{admin.id}", json={"is_active": False}, headers=headers
|
||||
)
|
||||
assert deactivate.status_code == 400
|
||||
|
||||
delete = client.delete(f"/api/access/users/{admin.id}", headers=headers)
|
||||
assert delete.status_code == 400
|
||||
|
||||
|
||||
def test_lean_users_cannot_be_deleted(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
lean_role = db.query(Role).filter_by(name="lean").one()
|
||||
lean_user = User(email="owner@hunterstockfeeds.com", name="Owner", role_id=lean_role.id, is_active=True)
|
||||
db.add(lean_user)
|
||||
db.commit()
|
||||
|
||||
listed = client.get("/api/access/users", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
owner_row = next(row for row in listed.json() if row["id"] == lean_user.id)
|
||||
assert owner_row["is_protected"] is True
|
||||
|
||||
response = client.delete(f"/api/access/users/{lean_user.id}", headers=headers)
|
||||
assert response.status_code == 403
|
||||
assert db.query(User).filter_by(id=lean_user.id).one_or_none() is not None
|
||||
|
||||
|
||||
def test_assignable_roles_lists_all_roles(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
|
||||
response = client.get("/api/access/assignable-roles", headers=headers)
|
||||
assert response.status_code == 200
|
||||
names = {row["name"] for row in response.json()}
|
||||
assert names == set(ROLE_DEFINITIONS.keys())
|
||||
|
||||
|
||||
def test_role_management_lists_modules_and_roles_for_admin(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
|
||||
modules = client.get("/api/access/role-modules", headers=headers)
|
||||
assert modules.status_code == 200
|
||||
module_keys = {row["key"] for row in modules.json()}
|
||||
assert {"dashboard", "mix_calculator", "roles", "settings"} <= module_keys
|
||||
|
||||
roles = client.get("/api/access/roles", headers=headers)
|
||||
assert roles.status_code == 200
|
||||
admin_role = next(row for row in roles.json() if row["name"] == "Admin")
|
||||
assert admin_role["is_protected"] is True
|
||||
assert admin_role["module_permissions"]["ordering"] == "manage"
|
||||
assert admin_role["module_permissions"]["roles"] == "manage"
|
||||
|
||||
|
||||
def test_role_management_is_blocked_for_non_admin_non_lean_roles(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
||||
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
|
||||
|
||||
response = client.get("/api/access/roles", headers=headers)
|
||||
assert response.status_code == 403
|
||||
assert "lean and admin" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_role_management_create_update_delete_custom_role(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
|
||||
created = client.post(
|
||||
"/api/access/roles",
|
||||
json={
|
||||
"name": "Reporting Viewer",
|
||||
"description": "Can review dashboards and reporting inputs",
|
||||
"module_permissions": {
|
||||
"dashboard": "view",
|
||||
"products": "view",
|
||||
"settings": "view",
|
||||
},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
created_body = created.json()
|
||||
assert created_body["module_permissions"]["dashboard"] == "view"
|
||||
assert created_body["module_permissions"]["products"] == "view"
|
||||
assert created_body["module_permissions"]["settings"] == "view"
|
||||
role_id = created_body["id"]
|
||||
|
||||
updated = client.patch(
|
||||
f"/api/access/roles/{role_id}",
|
||||
json={
|
||||
"description": "Can review and edit product data",
|
||||
"module_permissions": {
|
||||
"dashboard": "view",
|
||||
"products": "edit",
|
||||
"settings": "view",
|
||||
},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
updated_body = updated.json()
|
||||
assert updated_body["module_permissions"]["products"] == "edit"
|
||||
assert "edit_products" in updated_body["permissions"]
|
||||
|
||||
deleted = client.delete(f"/api/access/roles/{role_id}", headers=headers)
|
||||
assert deleted.status_code == 204
|
||||
assert db.query(Role).filter_by(id=role_id).one_or_none() is None
|
||||
|
||||
|
||||
def test_protected_or_assigned_roles_cannot_be_deleted(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
admin_role = db.query(Role).filter_by(name="Admin").one()
|
||||
|
||||
protected = client.delete(f"/api/access/roles/{admin_role.id}", headers=headers)
|
||||
assert protected.status_code == 403
|
||||
|
||||
full_access_role = db.query(Role).filter_by(name="Full Access").one()
|
||||
assigned = client.delete(f"/api/access/roles/{full_access_role.id}", headers=headers)
|
||||
assert assigned.status_code == 400
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""The change log records who edited a mix/ingredient and what changed.
|
||||
|
||||
Covers `record_change` / `diff_fields` / `list_changes`: edits are stored with a
|
||||
field-level before/after diff and read back newest-first per entity.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.db.session import Base
|
||||
from app.services.change_log import (
|
||||
ENTITY_INGREDIENT,
|
||||
ENTITY_MIX,
|
||||
diff_fields,
|
||||
list_changes,
|
||||
record_change,
|
||||
)
|
||||
|
||||
TENANT = "hunter-premium-produce"
|
||||
|
||||
LABELS = {"name": "Name", "kg_per_unit": "Kg per unit", "status": "Status"}
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||
|
||||
|
||||
def _actor() -> AuthSession:
|
||||
return AuthSession(role="internal", email="lara@hunter.test", name="Lara", tenant_id=TENANT, client_role="admin")
|
||||
|
||||
|
||||
def test_diff_fields_only_emits_changed_keys():
|
||||
deltas = diff_fields({"name": "Maize", "kg_per_unit": 25.0}, {"name": "Maize", "kg_per_unit": 30.0}, LABELS)
|
||||
assert len(deltas) == 1
|
||||
assert deltas[0]["field"] == "kg_per_unit"
|
||||
assert deltas[0]["before"] == "25"
|
||||
assert deltas[0]["after"] == "30"
|
||||
|
||||
|
||||
def test_records_and_lists_changes_newest_first():
|
||||
db = _session()
|
||||
session = _actor()
|
||||
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_INGREDIENT,
|
||||
entity_id=7,
|
||||
action="created",
|
||||
summary="Created ingredient “Maize”",
|
||||
)
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_INGREDIENT,
|
||||
entity_id=7,
|
||||
action="updated",
|
||||
summary="Updated Kg per unit",
|
||||
changes=diff_fields({"kg_per_unit": 25.0}, {"kg_per_unit": 30.0}, LABELS),
|
||||
)
|
||||
# A different entity must not leak into entity 7's history.
|
||||
record_change(db, session=session, entity_type=ENTITY_MIX, entity_id=7, action="created", summary="Created mix")
|
||||
db.commit()
|
||||
|
||||
events = list_changes(db, tenant_id=TENANT, entity_type=ENTITY_INGREDIENT, entity_id=7)
|
||||
assert [event.action for event in events] == ["updated", "created"]
|
||||
assert events[0].actor_name == "Lara"
|
||||
assert events[0].actor_role == "admin"
|
||||
assert events[0].changes[0]["label"] == "Kg per unit"
|
||||
|
||||
|
||||
def test_changes_are_tenant_scoped():
|
||||
db = _session()
|
||||
record_change(
|
||||
db,
|
||||
session=AuthSession(role="internal", email="x@y.test", name="X", tenant_id="other-tenant"),
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=1,
|
||||
action="created",
|
||||
summary="Created mix",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert list_changes(db, tenant_id=TENANT, entity_type=ENTITY_MIX, entity_id=1) == []
|
||||
@@ -482,7 +482,13 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
||||
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
|
||||
assert options_response.status_code == 200
|
||||
options_payload = options_response.json()
|
||||
assert len(options_payload["products"]) == 84
|
||||
# 83 product-backed mixes + 1 formula-only mix ("Hi Carb Popcorn", which
|
||||
# has a mix-master formula but no product yet, surfaced via a negative
|
||||
# product_id sentinel so a new mix is usable before a product is linked).
|
||||
assert len(options_payload["products"]) == 84 + 1
|
||||
formula_only = [product for product in options_payload["products"] if product["product_id"] < 0]
|
||||
assert len(formula_only) == 1
|
||||
assert formula_only[0]["unit_size_kg"] == 0
|
||||
seeded_product = next(
|
||||
product
|
||||
for product in options_payload["products"]
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads.
|
||||
|
||||
These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix
|
||||
whose product carries its own formula resolves to that product (not the shared
|
||||
mix master), percentages are computed against the total, and the representative
|
||||
product is chosen the way the calculator chooses it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.api.editor import delete_editor_mix, replace_editor_mix_formula
|
||||
from app.db.session import Base
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product, ProductIngredient
|
||||
from app.models.raw_material import RawMaterial
|
||||
from app.schemas.editor import EditorMixFormulaReplace, EditorMixFormulaRowInput
|
||||
from app.services.mix_calculator_service import (
|
||||
resolve_editor_mix_formula,
|
||||
resolve_representative_product,
|
||||
)
|
||||
|
||||
TENANT = "hunter-premium-produce"
|
||||
|
||||
|
||||
def _editor_session() -> AuthSession:
|
||||
return AuthSession(
|
||||
role="internal",
|
||||
email="editor@hunter.test",
|
||||
name="Editor",
|
||||
tenant_id=TENANT,
|
||||
client_role="admin",
|
||||
user_id=1,
|
||||
)
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||
|
||||
|
||||
def _raw(db: Session, name: str) -> RawMaterial:
|
||||
material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active")
|
||||
db.add(material)
|
||||
db.flush()
|
||||
return material
|
||||
|
||||
|
||||
def test_resolves_product_formula_not_mix_master():
|
||||
db = _session()
|
||||
bayley = _raw(db, "Bayley")
|
||||
filler = _raw(db, "Filler")
|
||||
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
|
||||
# Shared mix master says something different from the product formula.
|
||||
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100))
|
||||
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100))
|
||||
|
||||
product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
# The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%.
|
||||
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1))
|
||||
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2))
|
||||
db.commit()
|
||||
|
||||
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
|
||||
|
||||
assert formula["source"] == "product"
|
||||
assert formula["product_id"] == product.id
|
||||
assert formula["total_kg"] == 1320.41
|
||||
by_name = {row["raw_material_name"]: row for row in formula["ingredients"]}
|
||||
assert by_name["Bayley"]["quantity_kg"] == 787.5
|
||||
# Percentage matches the worked example (787.5 / 1320.41 * 100).
|
||||
assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001
|
||||
|
||||
|
||||
def test_falls_back_to_mix_master_when_no_product_formula():
|
||||
db = _session()
|
||||
maize = _raw(db, "Maize")
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
|
||||
db.commit()
|
||||
|
||||
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
|
||||
assert formula["source"] == "mix"
|
||||
assert formula["total_kg"] == 50
|
||||
assert formula["ingredients"][0]["mix_percentage"] == 100.0
|
||||
|
||||
|
||||
def test_replace_mix_master_formula_returns_fresh_rows():
|
||||
"""PUT formula on a mix without a product writes the mix master and the
|
||||
response reflects the just-saved rows (not the stale pre-save collection).
|
||||
|
||||
Regression: the diff path read the resolved formula by attribute, but the
|
||||
resolver returns dicts, which raised AttributeError -> HTTP 500 on save.
|
||||
"""
|
||||
db = _session()
|
||||
maize = _raw(db, "Maize")
|
||||
barley = _raw(db, "Barley")
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=100))
|
||||
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100))
|
||||
db.commit()
|
||||
|
||||
# Percentages need not total 100% — kg is canonical.
|
||||
payload = EditorMixFormulaReplace(
|
||||
rows=[
|
||||
EditorMixFormulaRowInput(raw_material_id=maize.id, quantity_kg=330.0, notes=None),
|
||||
EditorMixFormulaRowInput(raw_material_id=barley.id, quantity_kg=140.0, notes="confirmed"),
|
||||
]
|
||||
)
|
||||
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
|
||||
|
||||
assert result["source"] == "mix"
|
||||
assert result["total_kg"] == 470.0
|
||||
by_name = {row["raw_material_name"]: row for row in result["ingredients"]}
|
||||
assert by_name["Maize"]["quantity_kg"] == 330.0
|
||||
assert by_name["Barley"]["quantity_kg"] == 140.0
|
||||
|
||||
persisted = db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix.id)).all()
|
||||
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
|
||||
(maize.id, 330.0),
|
||||
(barley.id, 140.0),
|
||||
]
|
||||
|
||||
|
||||
def test_replace_product_formula_writes_product_ingredients():
|
||||
"""When a representative product owns the formula, PUT replaces the product's
|
||||
ingredients (the source the calculator reads) and returns the fresh rows."""
|
||||
db = _session()
|
||||
bayley = _raw(db, "Bayley")
|
||||
filler = _raw(db, "Filler")
|
||||
canola = _raw(db, "Canola")
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
product = Product(
|
||||
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
|
||||
unit_of_measure="20kg bag", visible=True,
|
||||
)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=10, sort_order=1))
|
||||
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=10, sort_order=2))
|
||||
db.commit()
|
||||
|
||||
payload = EditorMixFormulaReplace(
|
||||
rows=[
|
||||
EditorMixFormulaRowInput(raw_material_id=bayley.id, quantity_kg=600.0, notes=None),
|
||||
EditorMixFormulaRowInput(raw_material_id=canola.id, quantity_kg=200.0, notes=None),
|
||||
]
|
||||
)
|
||||
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
|
||||
|
||||
assert result["source"] == "product"
|
||||
assert result["product_id"] == product.id
|
||||
assert result["total_kg"] == 800.0
|
||||
|
||||
persisted = db.scalars(select(ProductIngredient).where(ProductIngredient.product_id == product.id)).all()
|
||||
# Filler dropped, Canola added; mix master is untouched.
|
||||
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
|
||||
(bayley.id, 600.0),
|
||||
(canola.id, 200.0),
|
||||
]
|
||||
|
||||
|
||||
def test_delete_mix_without_products_removes_mix_and_ingredients():
|
||||
"""A product-less mix can be deleted; its ingredient rows cascade away."""
|
||||
db = _session()
|
||||
maize = _raw(db, "Maize")
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
|
||||
db.commit()
|
||||
mix_id = mix.id
|
||||
|
||||
delete_editor_mix(mix_id, session=_editor_session(), db=db)
|
||||
|
||||
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is None
|
||||
assert db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix_id)).first() is None
|
||||
|
||||
|
||||
def test_delete_mix_with_products_is_refused():
|
||||
"""A mix that still drives products can't be deleted (409) — products must
|
||||
keep a mix, so the user marks it inactive instead."""
|
||||
db = _session()
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
db.add(
|
||||
Product(
|
||||
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
|
||||
unit_of_measure="20kg bag", visible=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
mix_id = mix.id
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
delete_editor_mix(mix_id, session=_editor_session(), db=db)
|
||||
|
||||
assert excinfo.value.status_code == 409
|
||||
assert "linked product" in excinfo.value.detail
|
||||
# The mix is left intact.
|
||||
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is not None
|
||||
|
||||
|
||||
def test_representative_product_prefers_20kg_bag():
|
||||
db = _session()
|
||||
maize = _raw(db, "Maize")
|
||||
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
|
||||
bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True)
|
||||
bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
|
||||
db.add_all([bulka, bag])
|
||||
db.flush()
|
||||
for product in (bulka, bag):
|
||||
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1))
|
||||
db.commit()
|
||||
|
||||
representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id)
|
||||
assert representative is not None
|
||||
assert representative.unit_of_measure == "20kg bag"
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for the read-only external data API (`/api/v1`) used by Power BI.
|
||||
|
||||
Drives the real FastAPI app via TestClient against an in-memory database, and
|
||||
swaps the configured API key in by replacing the module-level `settings` object
|
||||
(the real one is a frozen dataclass).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api import public_v1
|
||||
from app.core.config import settings
|
||||
from app.db.session import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.throughput import ProductionThroughput
|
||||
|
||||
API_KEY = "test-powerbi-key"
|
||||
TENANT = "hunter-premium-produce"
|
||||
|
||||
|
||||
def _seed_entry(db, *, tenant_id: str, product: str, quantity: float, when: date) -> None:
|
||||
db.add(
|
||||
ProductionThroughput(
|
||||
tenant_id=tenant_id,
|
||||
production_date=when,
|
||||
product_name_snapshot=product,
|
||||
bag_size=20,
|
||||
quantity=quantity,
|
||||
quantity_type="bags",
|
||||
calculated_kg=quantity * 20,
|
||||
created_by="test",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_factory():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
def override_get_db():
|
||||
db = TestingSession()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
yield TestingSession
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _make_client(monkeypatch, *, api_key: str) -> TestClient:
|
||||
# Replace the whole settings object the router reads (frozen dataclass).
|
||||
monkeypatch.setattr(public_v1, "settings", replace(settings, powerbi_api_key=api_key, powerbi_tenant_id=TENANT))
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_disabled_when_key_unset(monkeypatch, db_factory):
|
||||
client = _make_client(monkeypatch, api_key="")
|
||||
response = client.get("/api/v1/throughput", headers={"X-API-Key": "anything"})
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_rejects_missing_and_wrong_key(monkeypatch, db_factory):
|
||||
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||
assert client.get("/api/v1/throughput").status_code == 401
|
||||
assert client.get("/api/v1/throughput", headers={"X-API-Key": "nope"}).status_code == 401
|
||||
|
||||
|
||||
def test_returns_entries_for_tenant(monkeypatch, db_factory):
|
||||
db = db_factory()
|
||||
_seed_entry(db, tenant_id=TENANT, product="Maize", quantity=10, when=date(2026, 1, 5))
|
||||
_seed_entry(db, tenant_id=TENANT, product="Barley", quantity=5, when=date(2026, 1, 6))
|
||||
# An entry in another tenant must never leak through.
|
||||
_seed_entry(db, tenant_id="someone-else", product="Secret", quantity=99, when=date(2026, 1, 7))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||
|
||||
# Header auth.
|
||||
response = client.get("/api/v1/throughput", headers={"X-API-Key": API_KEY})
|
||||
assert response.status_code == 200
|
||||
rows = response.json()
|
||||
names = [row["product_name_snapshot"] for row in rows]
|
||||
assert names == ["Maize", "Barley"] # oldest first, other tenant excluded
|
||||
assert rows[0]["calculated_kg"] == 200.0
|
||||
|
||||
# Query-string auth works too (Power BI Web connector convenience).
|
||||
assert client.get(f"/api/v1/throughput?api_key={API_KEY}").status_code == 200
|
||||
|
||||
|
||||
def test_date_filter(monkeypatch, db_factory):
|
||||
db = db_factory()
|
||||
_seed_entry(db, tenant_id=TENANT, product="Old", quantity=1, when=date(2026, 1, 1))
|
||||
_seed_entry(db, tenant_id=TENANT, product="New", quantity=1, when=date(2026, 2, 1))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
client = _make_client(monkeypatch, api_key=API_KEY)
|
||||
response = client.get("/api/v1/throughput?date_from=2026-01-15", headers={"X-API-Key": API_KEY})
|
||||
assert [row["product_name_snapshot"] for row in response.json()] == ["New"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Regression guards for the print/PDF Content-Security-Policy.
|
||||
|
||||
The in-app print dialog loads a generated PDF as a same-origin ``blob:`` URL into
|
||||
an iframe and calls ``contentWindow.print()``. If the CSP omits ``frame-src`` /
|
||||
``child-src`` for ``blob:`` the directive falls back to ``default-src 'self'``,
|
||||
which silently blocks the frame and breaks printing for every user (regardless of
|
||||
role). These tests pin the policy on both layers that emit it:
|
||||
|
||||
* the FastAPI security middleware (covers every API response), and
|
||||
* the production nginx config (the source of the *document* CSP that actually
|
||||
governs ``frame-src`` in the browser).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import issue_token
|
||||
from app.main import app
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
NGINX_CONFIGS = [REPO_ROOT / "deploy" / "nginx" / "clients.lean-101.conf"]
|
||||
|
||||
# Directives the print flow depends on. blob: must be framable, and that must not
|
||||
# come at the cost of dropping the same-origin baseline.
|
||||
REQUIRED_FRAME_SOURCES = {"'self'", "blob:"}
|
||||
|
||||
|
||||
def _parse_csp(header: str) -> dict[str, set[str]]:
|
||||
"""Parse a CSP header string into ``{directive: {sources}}``."""
|
||||
directives: dict[str, set[str]] = {}
|
||||
for part in header.split(";"):
|
||||
tokens = part.split()
|
||||
if not tokens:
|
||||
continue
|
||||
directives[tokens[0].lower()] = set(tokens[1:])
|
||||
return directives
|
||||
|
||||
|
||||
def _assert_blob_framing(header: str) -> None:
|
||||
csp = _parse_csp(header)
|
||||
# frame-src must exist and allow self + blob (no falling back to default-src).
|
||||
assert "frame-src" in csp, f"frame-src missing from CSP: {header!r}"
|
||||
assert REQUIRED_FRAME_SOURCES <= csp["frame-src"], (
|
||||
f"frame-src must allow {REQUIRED_FRAME_SOURCES}, got {csp['frame-src']}"
|
||||
)
|
||||
# child-src is the Safari fallback for frame-src; keep it aligned.
|
||||
assert "child-src" in csp, f"child-src missing from CSP: {header!r}"
|
||||
assert "blob:" in csp["child-src"], f"child-src must allow blob:, got {csp['child-src']}"
|
||||
# We only widened framing: the same-origin default must stay intact.
|
||||
assert csp.get("default-src") == {"'self'"}, f"default-src weakened: {csp.get('default-src')}"
|
||||
|
||||
|
||||
# --- Backend middleware policy ------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_backend_csp_allows_blob_frames(client: TestClient) -> None:
|
||||
response = client.get("/health")
|
||||
assert "content-security-policy" in response.headers
|
||||
_assert_blob_framing(response.headers["content-security-policy"])
|
||||
|
||||
|
||||
def test_backend_csp_present_for_all_users(client: TestClient) -> None:
|
||||
"""The policy is identical for anonymous, authenticated, and rejected (401)
|
||||
requests, so printing can never depend on who is signed in."""
|
||||
admin_token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
|
||||
|
||||
responses = [
|
||||
client.get("/health"), # anonymous
|
||||
client.get("/api/access/me"), # the endpoint that 401s for warehouse users
|
||||
client.get("/api/access/me", headers={"Authorization": f"Bearer {admin_token}"}),
|
||||
]
|
||||
|
||||
policies = set()
|
||||
for response in responses:
|
||||
header = response.headers.get("content-security-policy")
|
||||
assert header is not None, f"CSP missing on {response.request.url} ({response.status_code})"
|
||||
_assert_blob_framing(header)
|
||||
policies.add(header)
|
||||
|
||||
assert len(policies) == 1, "CSP must not vary by authentication state"
|
||||
|
||||
|
||||
# --- Production document policy (nginx) ---------------------------------------
|
||||
|
||||
|
||||
def test_nginx_csp_allows_blob_frames() -> None:
|
||||
"""Every CSP the production nginx emits must allow blob framing. This guards
|
||||
the *document* policy, which is what the browser enforces for the print iframe."""
|
||||
csp_line = re.compile(r'Content-Security-Policy\s+"([^"]+)"', re.IGNORECASE)
|
||||
|
||||
for config_path in NGINX_CONFIGS:
|
||||
assert config_path.exists(), f"missing nginx config: {config_path}"
|
||||
text = config_path.read_text(encoding="utf-8")
|
||||
policies = csp_line.findall(text)
|
||||
assert policies, f"no Content-Security-Policy header found in {config_path}"
|
||||
for policy in policies:
|
||||
_assert_blob_framing(policy)
|
||||
@@ -13,6 +13,7 @@ from app.models.throughput import ProductionThroughput, ThroughputProduct
|
||||
from app.seed import seed_throughput_products_from_costing
|
||||
from app.services.throughput_service import (
|
||||
calculate_kg,
|
||||
import_entries_from_file,
|
||||
import_names_sheet,
|
||||
import_production_sheet,
|
||||
normalise_staff_name,
|
||||
@@ -240,3 +241,102 @@ def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
|
||||
assert products[0].name == "Updated Wheat 25kg"
|
||||
assert products[0].default_bag_size == 25
|
||||
assert products[0].active is True
|
||||
|
||||
|
||||
def test_upload_import_keeps_blank_destination_flags_false():
|
||||
db = _session()
|
||||
csv_bytes = (
|
||||
"Date,Product,Quantity,Type,Bag Size,For Order,For Stock,Job Number\n"
|
||||
"2026-06-12,Specialty Pigeon Breeder,40,bags,20,,,\n"
|
||||
).encode("utf-8")
|
||||
|
||||
result = import_entries_from_file(
|
||||
db,
|
||||
filename="throughput-import.csv",
|
||||
content=csv_bytes,
|
||||
tenant_id="test-tenant",
|
||||
created_by="tester@example.com",
|
||||
)
|
||||
|
||||
assert result["entries_imported"] == 1
|
||||
entry = db.scalar(select(ProductionThroughput))
|
||||
assert entry is not None
|
||||
assert entry.for_order is False
|
||||
assert entry.for_stock is False
|
||||
assert entry.job_number is None
|
||||
|
||||
|
||||
def test_upload_import_detects_month_first_dates_consistently():
|
||||
# The pasted sheet is US month-first (M/D/Y). "9/23/2025" is unambiguous, so
|
||||
# the ambiguous "12/9/2025" must follow the same convention: 9 December, not
|
||||
# 12 September (which the old day-first-by-default parser produced).
|
||||
db = _session()
|
||||
csv_bytes = (
|
||||
"Date,Product,Item ID,Quantity,Type,Bag Size,Packed By\n"
|
||||
"12/9/2025,Whole Wheat Cleaned & Graded 20kg,373022,156,bags,20,Jake\n"
|
||||
"9/23/2025,Steam Rolled Barley 20kg,568240,34,bags,20,jake\n"
|
||||
"9/24/2025,Stock Mix 20kg,540725,153,bags,20,jake\n"
|
||||
).encode("utf-8")
|
||||
|
||||
result = import_entries_from_file(
|
||||
db,
|
||||
filename="throughput-import.csv",
|
||||
content=csv_bytes,
|
||||
tenant_id="test-tenant",
|
||||
created_by="tester@example.com",
|
||||
)
|
||||
|
||||
assert result["entries_imported"] == 3
|
||||
dates = {
|
||||
e.product_name_snapshot: e.production_date
|
||||
for e in db.scalars(select(ProductionThroughput)).all()
|
||||
}
|
||||
assert dates["Whole Wheat Cleaned & Graded 20kg"] == date(2025, 12, 9)
|
||||
assert dates["Steam Rolled Barley 20kg"] == date(2025, 9, 23)
|
||||
assert dates["Stock Mix 20kg"] == date(2025, 9, 24)
|
||||
|
||||
|
||||
def test_upload_import_keeps_day_first_dates_for_australian_sheets():
|
||||
# A genuinely day-first file (23/9/2025 proves D/M/Y) must stay day-first, so
|
||||
# 12/9/2025 reads as 12 September.
|
||||
db = _session()
|
||||
csv_bytes = (
|
||||
"Date,Product,Quantity,Type,Bag Size\n"
|
||||
"23/9/2025,Stock Mix 20kg,10,bags,20\n"
|
||||
"12/9/2025,Stock Mix 20kg,10,bags,20\n"
|
||||
).encode("utf-8")
|
||||
|
||||
import_entries_from_file(
|
||||
db,
|
||||
filename="throughput-import.csv",
|
||||
content=csv_bytes,
|
||||
tenant_id="test-tenant",
|
||||
created_by="tester@example.com",
|
||||
)
|
||||
|
||||
produced = sorted(
|
||||
e.production_date for e in db.scalars(select(ProductionThroughput)).all()
|
||||
)
|
||||
assert produced == [date(2025, 9, 12), date(2025, 9, 23)]
|
||||
|
||||
|
||||
def test_upload_import_does_not_treat_unknown_destination_text_as_true():
|
||||
db = _session()
|
||||
csv_bytes = (
|
||||
"Date,Product,Quantity,Type,Bag Size,For Order,For Stock\n"
|
||||
"2026-06-12,Specialty Pigeon Breeder,40,bags,20,stock,\n"
|
||||
).encode("utf-8")
|
||||
|
||||
result = import_entries_from_file(
|
||||
db,
|
||||
filename="throughput-import.csv",
|
||||
content=csv_bytes,
|
||||
tenant_id="test-tenant",
|
||||
created_by="tester@example.com",
|
||||
)
|
||||
|
||||
assert result["entries_imported"] == 1
|
||||
entry = db.scalar(select(ProductionThroughput))
|
||||
assert entry is not None
|
||||
assert entry.for_order is False
|
||||
assert entry.for_stock is False
|
||||
|
||||
@@ -458,6 +458,21 @@ fi
|
||||
throw
|
||||
}
|
||||
|
||||
# ── Reload nginx config ───────────────────────────────────────────────────
|
||||
# The nginx config is a bind-mounted file. `docker compose up` only recreates
|
||||
# a service when its definition changes, not when a mounted file's contents
|
||||
# change, so a running nginx keeps serving the config it loaded at start. Force
|
||||
# a reload so edits to clients.lean-101.conf (routing, security headers/CSP)
|
||||
# actually take effect on every deploy. Non-fatal: stacks without an nginx
|
||||
# service simply skip this.
|
||||
Write-Step "Reloading nginx to apply config changes"
|
||||
$nginxReload = "cd '$RemotePath' && docker compose $ComposeArgs exec -T nginx nginx -t && docker compose $ComposeArgs exec -T nginx nginx -s reload"
|
||||
if ((Try-Ssh $nginxReload) -eq 0) {
|
||||
Write-Ok "nginx reloaded"
|
||||
} else {
|
||||
Write-Warn "Skipped nginx reload (no nginx service, or config test failed)"
|
||||
}
|
||||
|
||||
# ── Health check ────────────────────────────────────────────────────────────
|
||||
Write-Step "Waiting for backend health check ($BackendContainer)"
|
||||
$healthScript = @"
|
||||
|
||||
@@ -457,19 +457,27 @@ def migrate():
|
||||
# Re-enable FK checks
|
||||
dst_conn.execute(text("SET session_replication_role = 'origin'"))
|
||||
|
||||
# Reset auto-increment sequences
|
||||
# Reset auto-increment sequences for EVERY table with an id sequence — not
|
||||
# just the ones we copied above — so later inserts (e.g. editor_change_events)
|
||||
# don't collide with pre-existing ids. Leaving a sequence behind MAX(id) is
|
||||
# what makes "create new mix" fail with a duplicate-key error on Postgres.
|
||||
print("\n Resetting sequences...")
|
||||
with dst.begin() as conn:
|
||||
for table_name in TABLE_ORDER:
|
||||
try:
|
||||
conn.execute(text(
|
||||
f"SELECT setval("
|
||||
f" pg_get_serial_sequence('{table_name}', 'id'),"
|
||||
f" COALESCE((SELECT MAX(id) FROM {table_name}), 1)"
|
||||
f")"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
all_tables = inspect(dst).get_table_names()
|
||||
for table_name in all_tables:
|
||||
sequence = conn.execute(text(
|
||||
"SELECT pg_get_serial_sequence(:table, 'id')"
|
||||
), {"table": table_name}).scalar()
|
||||
if not sequence:
|
||||
continue
|
||||
max_id = conn.execute(text(f'SELECT MAX(id) FROM "{table_name}"')).scalar()
|
||||
if max_id is None:
|
||||
continue
|
||||
conn.execute(text("SELECT setval(:sequence, :value, true)"), {
|
||||
"sequence": sequence,
|
||||
"value": int(max_id),
|
||||
})
|
||||
print(f" SEQ {table_name:<45} -> {max_id}")
|
||||
|
||||
print(f"\n Migration complete. {sum(totals.values())} rows across {len(totals)} tables.")
|
||||
return totals
|
||||
|
||||
@@ -27,7 +27,9 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||
# frame-src/child-src allow same-origin blob: URLs so the in-app PDF print
|
||||
# dialog (an iframe pointed at a blob:) is not blocked by the default-src fallback.
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
location /_app/immutable/ {
|
||||
expires 1y;
|
||||
@@ -90,6 +92,14 @@ server {
|
||||
location / {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
# nginx drops inherited add_header directives once a location defines its own,
|
||||
# so the security headers (incl. the blob:-aware CSP) are repeated here to
|
||||
# guarantee the HTML document carries them.
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; child-src 'self' blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
|
||||
expires -1;
|
||||
proxy_pass http://lean101_clients_frontend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -45,6 +45,9 @@ services:
|
||||
LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8}
|
||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300}
|
||||
DOCS_ENABLED: ${DOCS_ENABLED:-false}
|
||||
# Read-only Power BI data API (/api/v1). Blank disables it.
|
||||
POWERBI_API_KEY: ${POWERBI_API_KEY:-}
|
||||
POWERBI_TENANT_ID: ${POWERBI_TENANT_ID:-${CLIENT_TENANT_ID:-hunter-premium-produce}}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.36",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"lucide-svelte": "^1.0.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.36",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Modern hover/focus tooltip action.
|
||||
*
|
||||
* Renders a styled bubble appended to <body> with fixed positioning, so it is
|
||||
* never clipped by an overflow:hidden ancestor (e.g. the topbar). Themes via the
|
||||
* shared design tokens — see `.app-tooltip` in styles/theme.css.
|
||||
*
|
||||
* Usage: <button use:tooltip={'Switch to dark mode'}>…</button>
|
||||
* <button use:tooltip={{ label: 'What’s new', placement: 'bottom' }}>…</button>
|
||||
*/
|
||||
type Placement = 'top' | 'bottom';
|
||||
|
||||
type TooltipOptions = string | { label: string; placement?: Placement; delay?: number };
|
||||
|
||||
const GAP = 8;
|
||||
const DEFAULT_DELAY = 300;
|
||||
|
||||
function normalize(options: TooltipOptions): { label: string; placement: Placement; delay: number } {
|
||||
if (typeof options === 'string') {
|
||||
return { label: options, placement: 'bottom', delay: DEFAULT_DELAY };
|
||||
}
|
||||
return {
|
||||
label: options.label,
|
||||
placement: options.placement ?? 'bottom',
|
||||
delay: options.delay ?? DEFAULT_DELAY
|
||||
};
|
||||
}
|
||||
|
||||
export function tooltip(node: HTMLElement, options: TooltipOptions) {
|
||||
let current = normalize(options);
|
||||
let bubble: HTMLDivElement | null = null;
|
||||
let showTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function position() {
|
||||
if (!bubble) return;
|
||||
const anchor = node.getBoundingClientRect();
|
||||
const tip = bubble.getBoundingClientRect();
|
||||
|
||||
let left = anchor.left + anchor.width / 2 - tip.width / 2;
|
||||
left = Math.max(GAP, Math.min(left, window.innerWidth - tip.width - GAP));
|
||||
|
||||
const top =
|
||||
current.placement === 'top'
|
||||
? anchor.top - tip.height - GAP
|
||||
: anchor.bottom + GAP;
|
||||
|
||||
bubble.style.left = `${Math.round(left)}px`;
|
||||
bubble.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (bubble || !current.label) return;
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'app-tooltip';
|
||||
bubble.setAttribute('role', 'tooltip');
|
||||
bubble.dataset.placement = current.placement;
|
||||
bubble.textContent = current.label;
|
||||
document.body.appendChild(bubble);
|
||||
position();
|
||||
// Trigger the fade/translate transition on the next frame.
|
||||
requestAnimationFrame(() => bubble?.classList.add('is-visible'));
|
||||
}
|
||||
|
||||
function scheduleShow() {
|
||||
clearTimeout(showTimer ?? undefined);
|
||||
showTimer = setTimeout(show, current.delay);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
clearTimeout(showTimer ?? undefined);
|
||||
showTimer = null;
|
||||
bubble?.remove();
|
||||
bubble = null;
|
||||
}
|
||||
|
||||
node.addEventListener('mouseenter', scheduleShow);
|
||||
node.addEventListener('mouseleave', hide);
|
||||
node.addEventListener('focus', show);
|
||||
node.addEventListener('blur', hide);
|
||||
node.addEventListener('click', hide);
|
||||
|
||||
return {
|
||||
update(next: TooltipOptions) {
|
||||
current = normalize(next);
|
||||
if (bubble) {
|
||||
bubble.textContent = current.label;
|
||||
bubble.dataset.placement = current.placement;
|
||||
position();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
hide();
|
||||
node.removeEventListener('mouseenter', scheduleShow);
|
||||
node.removeEventListener('mouseleave', hide);
|
||||
node.removeEventListener('focus', show);
|
||||
node.removeEventListener('blur', hide);
|
||||
node.removeEventListener('click', hide);
|
||||
}
|
||||
};
|
||||
}
|
||||
+184
-2
@@ -7,8 +7,25 @@ import type {
|
||||
ClientUserCreateInput,
|
||||
ClientUserModulePermission,
|
||||
ClientUserUpdateInput,
|
||||
InternalUser,
|
||||
InternalRoleOption,
|
||||
InternalRole,
|
||||
InternalRoleCreateInput,
|
||||
InternalRoleModuleDefinition,
|
||||
InternalRoleUpdateInput,
|
||||
InternalUserCreateInput,
|
||||
InternalUserUpdateInput,
|
||||
LoginResponse,
|
||||
EditorMixCreateInput,
|
||||
EditorMixUpdateInput,
|
||||
EditorMixRow,
|
||||
EditorMixFormula,
|
||||
EditorResolvedMixFormula,
|
||||
EditorMixFormulaRowInput,
|
||||
EditorIngredientRow,
|
||||
EditorIngredientCreateInput,
|
||||
EditorIngredientUpdateInput,
|
||||
EditorChangeEvent,
|
||||
EditorProductFormula,
|
||||
EditorProductRow,
|
||||
EditorProductUpdateInput,
|
||||
@@ -38,10 +55,15 @@ import type {
|
||||
OrderingCustomerUser,
|
||||
OrderingNotificationSettings,
|
||||
XeroStatus,
|
||||
XeroContactList,
|
||||
XeroContactLinkRow,
|
||||
Scenario,
|
||||
ThroughputDeleteAllResult,
|
||||
ThroughputEntry,
|
||||
ThroughputEntryCreateInput,
|
||||
ThroughputEntryUpdateInput,
|
||||
ThroughputEntryListParams,
|
||||
ThroughputImportResult,
|
||||
ThroughputProduct,
|
||||
ThroughputProductCreateInput,
|
||||
ThroughputProductUpdateInput
|
||||
@@ -250,6 +272,45 @@ async function request<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// Multipart upload. Unlike `request`, we must NOT set Content-Type ourselves —
|
||||
// the browser sets `multipart/form-data` with the correct boundary when given a
|
||||
// FormData body. Mirrors `request`'s auth/cache/error handling otherwise.
|
||||
async function uploadFile<T>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
auth: AuthMode = 'none',
|
||||
fetcher: ApiFetch = fetch
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = 'Request failed';
|
||||
try {
|
||||
const body = (await response.json()) as { detail?: string };
|
||||
message = body.detail ?? message;
|
||||
} catch {
|
||||
message = response.statusText || message;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
clearApiCache();
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
throw normalizeRequestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestBlob(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
@@ -330,11 +391,54 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
editorMixes: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.q) search.set('q', params.q);
|
||||
if (params?.client_name) search.set('client_name', params.client_name);
|
||||
if (params?.limit) search.set('limit', String(params.limit));
|
||||
const qs = search.toString();
|
||||
const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes';
|
||||
return cachedFetchJson<EditorMixRow[]>(path, 'client', fetcher);
|
||||
},
|
||||
createEditorMix: (payload: EditorMixCreateInput) =>
|
||||
request<EditorMixRow>('/api/editor/mixes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
|
||||
request<EditorProductRow[]>(`/api/editor/mixes/${mixId}`, {
|
||||
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
deleteEditorMix: (mixId: number) =>
|
||||
request<void>(`/api/editor/mixes/${mixId}`, {
|
||||
method: 'DELETE'
|
||||
}, 'client'),
|
||||
editorMixFormula: (mixId: number) =>
|
||||
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
|
||||
// The resolved formula matching the Mix Calculator (product-first), used by
|
||||
// the Mix Editor ingredient panel.
|
||||
editorMixResolvedFormula: (mixId: number) =>
|
||||
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {}, 'client'),
|
||||
replaceEditorMixFormula: (mixId: number, rows: EditorMixFormulaRowInput[]) =>
|
||||
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rows })
|
||||
}, 'client'),
|
||||
addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
|
||||
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateEditorMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
|
||||
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
deleteEditorMixIngredient: (mixId: number, ingredientId: number) =>
|
||||
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
|
||||
method: 'DELETE'
|
||||
}, 'client'),
|
||||
editorProductFormula: (productId: number) =>
|
||||
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'),
|
||||
addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
|
||||
@@ -351,6 +455,22 @@ export const api = {
|
||||
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
|
||||
method: 'DELETE'
|
||||
}, 'client'),
|
||||
editorIngredients: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<EditorIngredientRow[]>('/api/editor/ingredients', 'client', fetcher),
|
||||
createEditorIngredient: (payload: EditorIngredientCreateInput) =>
|
||||
request<EditorIngredientRow>('/api/editor/ingredients', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateEditorIngredient: (ingredientId: number, payload: EditorIngredientUpdateInput) =>
|
||||
request<EditorIngredientRow>(`/api/editor/ingredients/${ingredientId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
editorMixHistory: (mixId: number) =>
|
||||
request<EditorChangeEvent[]>(`/api/editor/mixes/${mixId}/history`, {}, 'client'),
|
||||
editorIngredientHistory: (ingredientId: number) =>
|
||||
request<EditorChangeEvent[]>(`/api/editor/ingredients/${ingredientId}/history`, {}, 'client'),
|
||||
productCosts: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
|
||||
productCostingItems: (fetcher?: ApiFetch) =>
|
||||
@@ -391,6 +511,20 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateThroughputEntry: (entryId: number, payload: ThroughputEntryUpdateInput) =>
|
||||
request<ThroughputEntry>(`/api/throughput/entries/${entryId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
deleteThroughputEntry: (entryId: number) =>
|
||||
request<void>(`/api/throughput/entries/${entryId}`, { method: 'DELETE' }, 'client'),
|
||||
importThroughputEntries: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client');
|
||||
},
|
||||
deleteAllThroughputEntries: () =>
|
||||
request<ThroughputDeleteAllResult>('/api/throughput/entries', { method: 'DELETE' }, 'client'),
|
||||
createThroughputProduct: (payload: ThroughputProductCreateInput) =>
|
||||
request<ThroughputProduct>('/api/throughput/products', {
|
||||
method: 'POST',
|
||||
@@ -427,6 +561,44 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
// --- Internal user management (lean/admin: manage_users) ------------------
|
||||
accessUsers: (fetcher?: ApiFetch) =>
|
||||
request<InternalUser[]>('/api/access/users', { method: 'GET' }, 'client', fetcher),
|
||||
accessAssignableRoles: (fetcher?: ApiFetch) =>
|
||||
request<InternalRoleOption[]>('/api/access/assignable-roles', { method: 'GET' }, 'client', fetcher),
|
||||
accessRoles: (fetcher?: ApiFetch) =>
|
||||
request<InternalRole[]>('/api/access/roles', { method: 'GET' }, 'client', fetcher),
|
||||
accessRoleModules: (fetcher?: ApiFetch) =>
|
||||
request<InternalRoleModuleDefinition[]>('/api/access/role-modules', { method: 'GET' }, 'client', fetcher),
|
||||
createAccessRole: (payload: InternalRoleCreateInput) =>
|
||||
request<InternalRole>('/api/access/roles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateAccessRole: (roleId: number, payload: InternalRoleUpdateInput) =>
|
||||
request<InternalRole>(`/api/access/roles/${roleId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
deleteAccessRole: (roleId: number) =>
|
||||
request<void>(`/api/access/roles/${roleId}`, { method: 'DELETE' }, 'client'),
|
||||
createAccessUser: (payload: InternalUserCreateInput) =>
|
||||
request<InternalUser>('/api/access/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
updateAccessUser: (userId: number, payload: InternalUserUpdateInput) =>
|
||||
request<InternalUser>(`/api/access/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
setAccessUserPassword: (userId: number, newPassword: string) =>
|
||||
request<InternalUser>(`/api/access/users/${userId}/password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: newPassword })
|
||||
}, 'client'),
|
||||
deleteAccessUser: (userId: number) =>
|
||||
request<void>(`/api/access/users/${userId}`, { method: 'DELETE' }, 'client'),
|
||||
adminLogin: (email: string, password: string) =>
|
||||
request<LoginResponse>('/api/auth/admin/login', {
|
||||
method: 'POST',
|
||||
@@ -579,6 +751,16 @@ export const api = {
|
||||
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
|
||||
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
|
||||
request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
||||
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher)
|
||||
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher),
|
||||
xeroContacts: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<XeroContactList>('/api/ordering-admin/xero/contacts', 'client', fetcher),
|
||||
xeroContactLinks: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<XeroContactLinkRow[]>('/api/ordering-admin/xero/contact-links', 'client', fetcher),
|
||||
linkCustomerToXero: (
|
||||
customerId: number,
|
||||
payload: { xero_contact_id: string; xero_contact_name?: string | null; xero_contact_email?: string | null }
|
||||
) => request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
|
||||
unlinkCustomerFromXero: (customerId: number) =>
|
||||
request<void>(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'DELETE' }, 'client')
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,14 +17,146 @@ export type ChangelogEntry = {
|
||||
export const APP_VERSION: string = packageInfo.version;
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: '0.1.36',
|
||||
date: '2026-06-21',
|
||||
highlights: [
|
||||
'App: Bug fixes & improvements.',
|
||||
'App: General improvements.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.35',
|
||||
date: '2026-06-21',
|
||||
highlights: [
|
||||
'App: Bug fixes & improvements.',
|
||||
'App: General improvements.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.34',
|
||||
date: '2026-06-21',
|
||||
highlights: [
|
||||
'App: Bug fixes & improvements.',
|
||||
'App: General improvements.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.33',
|
||||
date: '2026-06-21',
|
||||
highlights: [
|
||||
'App: Mix Calculator & Ingredients improvements.',
|
||||
'App: Bug fixes & improvements.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.32',
|
||||
date: '2026-06-21',
|
||||
highlights: [
|
||||
'Mix Calculator: Added search.',
|
||||
'Ingredients: Added ingredient categories.',
|
||||
'Throughput: Tidy-up and refinements.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.31',
|
||||
date: '2026-06-18',
|
||||
highlights: [
|
||||
'Mix Editor: Multi-row editing.',
|
||||
'App: Bug fixes & improvements.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.30',
|
||||
date: '2026-06-18',
|
||||
highlights: [
|
||||
'Throughput: Overview now shows today-only mix cards.',
|
||||
'Mix Editor: Fixed an error when saving a mix formula.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.29',
|
||||
date: '2026-06-18',
|
||||
highlights: [
|
||||
'Mix Editor: Editing a % no longer rebalances the other ingredients.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.28',
|
||||
date: '2026-06-17',
|
||||
highlights: [
|
||||
'Editor & Throughput: Updates and improvements.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.27',
|
||||
date: '2026-06-16',
|
||||
highlights: [
|
||||
'Editor: Edit a mix’s resolved formula directly, with % and kg entry on each row.',
|
||||
'Editor: New mix and new ingredient buttons.',
|
||||
'Throughput: Power BI / external API now live.',
|
||||
'App: Security hardening on API responses.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.23',
|
||||
date: '2026-06-15',
|
||||
highlights: [
|
||||
'App: Improvements & bug fixes.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.22',
|
||||
date: '2026-06-15',
|
||||
highlights: [
|
||||
'Web App - Throughput module is now live.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.21',
|
||||
date: '2026-06-14',
|
||||
highlights: [
|
||||
'Mix Calculator: Composer restyle.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.20',
|
||||
date: '2026-06-13',
|
||||
highlights: [
|
||||
'App - Improvements',
|
||||
'App - Bug fixes'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.19',
|
||||
date: '2026-06-13',
|
||||
highlights: [
|
||||
'Throughput: Overview view added.',
|
||||
'App: Responsive header.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.18',
|
||||
date: '2026-06-12',
|
||||
highlights: [
|
||||
'App - Improvements',
|
||||
'App - Bug fixes'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.17',
|
||||
date: '2026-06-12',
|
||||
highlights: [
|
||||
'App - Improvements',
|
||||
'App - Mix Calculator bug fixes'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.14',
|
||||
date: '2026-06-11',
|
||||
highlights: [
|
||||
'New: private B2B customer ordering portal — customers browse their catalogue, see account-specific pricing, and submit orders.',
|
||||
'Order management console for internal staff: review orders, manage products, pricing, and the full order lifecycle.',
|
||||
'Customer-specific pricing engine (fixed, contract, price lists, tiered, and quote-only) calculated on the backend.',
|
||||
'Order confirmations (PDF) and Xero submission, behind a clean integration layer.'
|
||||
'Web App: Improved mix calculator',
|
||||
'Web App: Improved design'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -33,9 +165,16 @@ export const changelog: ChangelogEntry[] = [
|
||||
highlights: [
|
||||
'Mix Calculator: Changed from selecting Product to Mix.',
|
||||
'Web app design improved',
|
||||
'Throughput tab ready for testing',
|
||||
'Throughput tab ready for testing',
|
||||
'Costing Editor tab ready for testing'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.11',
|
||||
date: '2026-06-03',
|
||||
highlights: [
|
||||
'Costing Editor: First release.'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -44,5 +183,14 @@ export function changelogFor(version: string): ChangelogEntry | undefined {
|
||||
return changelog.find((entry) => entry.version === version);
|
||||
}
|
||||
|
||||
/** The entry for the version the app is currently running, if documented. */
|
||||
export const currentChangelog: ChangelogEntry | undefined = changelogFor(APP_VERSION);
|
||||
/** The most recent changelog entry, regardless of the running version. */
|
||||
export const latestChangelog: ChangelogEntry | undefined = changelog[0];
|
||||
|
||||
/**
|
||||
* The entry the "What's new" dialog shows. Prefer an exact match for the running
|
||||
* version, but fall back to the latest documented entry so the manual button
|
||||
* always opens something even when the current build's version (e.g. a hotfix
|
||||
* suffix like `0.1.24b`) isn't itself listed in the changelog.
|
||||
*/
|
||||
export const currentChangelog: ChangelogEntry | undefined =
|
||||
changelogFor(APP_VERSION) ?? latestChangelog;
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import AuthGate from '$lib/components/AuthGate.svelte';
|
||||
import WorkspaceBootCard from '$lib/components/app-shell/WorkspaceBootCard.svelte';
|
||||
import WorkspaceSignedOutCard from '$lib/components/app-shell/WorkspaceSignedOutCard.svelte';
|
||||
import WorkspaceTabletNav from '$lib/components/app-shell/WorkspaceTabletNav.svelte';
|
||||
import WorkspaceAppsFab, { type WorkspaceFabItem } from '$lib/components/WorkspaceAppsFab.svelte';
|
||||
import { PALETTE_RESULT_LIMIT, buildSessionKey, filterSearchItems } from '$lib/components/app-shell/utils';
|
||||
import ClientPrimaryRail from '$lib/components/navigation/ClientPrimaryRail.svelte';
|
||||
import ClientTopbar from '$lib/components/navigation/ClientTopbar.svelte';
|
||||
import WorkspacePageHeader from '$lib/components/navigation/WorkspacePageHeader.svelte';
|
||||
import WhatsNewDialog from '$lib/components/WhatsNewDialog.svelte';
|
||||
import { currentChangelog } from '$lib/changelog';
|
||||
import { hasSeenVersion, markVersionSeen } from '$lib/whats-new';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
|
||||
import { featureFlags } from '$lib/features';
|
||||
import {
|
||||
canCreateMixSession as sessionCanCreateMixSession,
|
||||
canCreateMixWorksheet as sessionCanCreateMixWorksheet,
|
||||
canOpenClientAccess as sessionCanOpenClientAccess,
|
||||
canOpenDashboard as sessionCanOpenDashboard,
|
||||
canOpenEditor as sessionCanOpenEditor,
|
||||
canOpenMixCalculator as sessionCanOpenMixCalculator,
|
||||
canOpenMixMaster as sessionCanOpenMixMaster,
|
||||
canOpenCustomerOrdering as sessionCanOpenCustomerOrdering,
|
||||
canManageOrdering as sessionCanManageOrdering,
|
||||
canOpenProductCosting as sessionCanOpenProductCosting,
|
||||
canOpenReporting as sessionCanOpenReporting,
|
||||
canOpenSettings as sessionCanOpenSettings,
|
||||
canOpenThroughput as sessionCanOpenThroughput,
|
||||
canUseWorkspaceSearch as sessionCanUseWorkspaceSearch,
|
||||
getWorkspaceRole,
|
||||
getWorkspaceHomeHref as sessionWorkspaceHomeHref,
|
||||
isWorkspaceRouteAllowed
|
||||
} from '$lib/workspace-access';
|
||||
import {
|
||||
accessControlItem,
|
||||
baseSearchItems,
|
||||
buildClientNavEntries,
|
||||
dashboardItem,
|
||||
editorItem,
|
||||
ingredientsEditorItem,
|
||||
footerLinks,
|
||||
mixCalculatorItem,
|
||||
orderingItem,
|
||||
orderingManageChildren,
|
||||
orderingManageGroup,
|
||||
pageMeta,
|
||||
productCostingItem,
|
||||
reportingItem,
|
||||
throughputItem,
|
||||
type FooterLink,
|
||||
type NavEntry,
|
||||
type SearchItem,
|
||||
type NavItem,
|
||||
workingDocumentItems
|
||||
} from '$lib/navigation/client-navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import packageInfo from '../../../package.json';
|
||||
|
||||
let { children } = $props();
|
||||
const isRootRoute = $derived(page.url.pathname === '/');
|
||||
|
||||
let searchOpen = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let searchFocusRequest = $state(0);
|
||||
let appsFabOpen = $state(false);
|
||||
let sidebarOpen = $state(true);
|
||||
let userMenuOpen = $state(false);
|
||||
let navOpen = $state(false);
|
||||
let showBottomNav = $state(false);
|
||||
let whatsNewOpen = $state(false);
|
||||
// The user identity we've already run the "what's new" check for this mount,
|
||||
// so the dialog is evaluated once per login rather than on every navigation.
|
||||
let whatsNewCheckedFor = $state<string | null>(null);
|
||||
let isRestoringSession = $state(false);
|
||||
let restoredSessionKey = $state<string | null>(null);
|
||||
let seededSearchItems = $state<SearchItem[]>([]);
|
||||
let seededSearchKey = $state<string | null>(null);
|
||||
let bootDelayDone = $state(false);
|
||||
let sidebarStateReady = $state(false);
|
||||
const SIDEBAR_STORAGE_KEY = 'hsf:shell:sidebar-open';
|
||||
const appVersion = `v${packageInfo.version}`;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const canOpenDashboard = $derived(sessionCanOpenDashboard($clientSession));
|
||||
const canOpenMixMaster = $derived(sessionCanOpenMixMaster($clientSession));
|
||||
const canCreateMixWorksheet = $derived(sessionCanCreateMixWorksheet($clientSession));
|
||||
const canOpenMixCalculator = $derived(sessionCanOpenMixCalculator($clientSession));
|
||||
const canCreateMixSession = $derived(sessionCanCreateMixSession($clientSession));
|
||||
const canOpenEditor = $derived(sessionCanOpenEditor($clientSession));
|
||||
const canOpenSettings = $derived(sessionCanOpenSettings($clientSession));
|
||||
const canUseWorkspaceSearch = $derived(sessionCanUseWorkspaceSearch($clientSession));
|
||||
const workspaceHomeHref = $derived(sessionWorkspaceHomeHref($clientSession));
|
||||
const currentRouteAllowed = $derived(isWorkspaceRouteAllowed($clientSession, page.url.pathname));
|
||||
const routeGuardPending = $derived(!!$clientSession && (isRestoringSession || !currentRouteAllowed));
|
||||
const shellPathname = $derived(routeGuardPending ? workspaceHomeHref : page.url.pathname);
|
||||
const shellPageMeta = $derived(
|
||||
routeGuardPending
|
||||
? { title: 'Loading Workspace', category: 'Workspace', icon: dashboardItem.icon }
|
||||
: pageMeta(page.url.pathname)
|
||||
);
|
||||
const visibleDashboardItem = $derived(canOpenDashboard ? dashboardItem : null);
|
||||
const visibleWorkingDocumentItems = $derived(
|
||||
!$clientSession
|
||||
? workingDocumentItems
|
||||
: workingDocumentItems.filter((item) => {
|
||||
if (item.href === '/mixes') return canOpenMixMaster;
|
||||
return !item.moduleKey || hasModuleAccess($clientSession, item.moduleKey);
|
||||
})
|
||||
);
|
||||
const visibleMixCalculatorItem = $derived(canOpenMixCalculator ? mixCalculatorItem : null);
|
||||
const visibleProductCostingItem = $derived(sessionCanOpenProductCosting($clientSession) ? productCostingItem : null);
|
||||
const canOpenThroughput = $derived(sessionCanOpenThroughput($clientSession));
|
||||
const visibleThroughputItem = $derived(canOpenThroughput ? throughputItem : null);
|
||||
// Ordering serves two audiences: internal staff get the management console
|
||||
// (/ordering/manage), customers get the catalogue (/ordering).
|
||||
const canManageOrdering = $derived(sessionCanManageOrdering($clientSession));
|
||||
const canOpenCustomerOrdering = $derived(sessionCanOpenCustomerOrdering($clientSession));
|
||||
// Internal staff get the collapsible "Order Management" family (queue +
|
||||
// products/customers/pricing/settings/integrations); customers get the single
|
||||
// catalogue link. Either way it becomes one NavEntry the rail can render.
|
||||
const visibleOrderingEntry = $derived<NavEntry | null>(
|
||||
canManageOrdering
|
||||
? { kind: 'group', group: orderingManageGroup }
|
||||
: canOpenCustomerOrdering
|
||||
? { kind: 'item', item: orderingItem }
|
||||
: null
|
||||
);
|
||||
const visibleReportingItem = $derived(sessionCanOpenReporting($clientSession) ? reportingItem : null);
|
||||
const visibleEditorItem = $derived(canOpenEditor ? editorItem : null);
|
||||
const visibleIngredientsEditorItem = $derived(canOpenEditor ? ingredientsEditorItem : null);
|
||||
const visibleAccessControlItem = $derived(sessionCanOpenClientAccess($clientSession) ? accessControlItem : null);
|
||||
// Grouped desktop rail: Dashboard, a collapsible "Operations" family (mix
|
||||
// calculator plus throughput), a "Costing" family (product costing and the
|
||||
// editors), then the standalone ordering/insights modules. Built from the same
|
||||
// access-filtered items, so a role only ever sees the families it may open.
|
||||
const navEntries = $derived(
|
||||
buildClientNavEntries({
|
||||
dashboard: visibleDashboardItem,
|
||||
operations: [
|
||||
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
||||
...(visibleThroughputItem ? [visibleThroughputItem] : [])
|
||||
],
|
||||
costing: [
|
||||
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
|
||||
...(visibleEditorItem ? [visibleEditorItem] : []),
|
||||
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
|
||||
...visibleWorkingDocumentItems
|
||||
],
|
||||
ordering: visibleOrderingEntry,
|
||||
reporting: visibleReportingItem
|
||||
})
|
||||
);
|
||||
const isOperationsUser = $derived($clientSession?.role_name === 'Operations');
|
||||
const workspaceRole = $derived(getWorkspaceRole($clientSession));
|
||||
const visibleFooterLinks = $derived([
|
||||
...(!isOperationsUser ? footerLinks : [])
|
||||
] as FooterLink[]);
|
||||
const fabItems = $derived.by(() => {
|
||||
const items = [
|
||||
visibleDashboardItem,
|
||||
...visibleWorkingDocumentItems,
|
||||
visibleMixCalculatorItem,
|
||||
visibleProductCostingItem,
|
||||
visibleThroughputItem,
|
||||
visibleOrderingEntry?.kind === 'item'
|
||||
? visibleOrderingEntry.item
|
||||
: visibleOrderingEntry?.group
|
||||
? {
|
||||
href: visibleOrderingEntry.group.href ?? '/ordering/manage',
|
||||
label: visibleOrderingEntry.group.label,
|
||||
icon: visibleOrderingEntry.group.icon
|
||||
}
|
||||
: null,
|
||||
visibleReportingItem,
|
||||
visibleEditorItem,
|
||||
visibleIngredientsEditorItem,
|
||||
visibleAccessControlItem
|
||||
].filter((item): item is { href: string; label: string; icon: WorkspaceFabItem['icon'] } => Boolean(item));
|
||||
|
||||
const seen = new Set<string>();
|
||||
return items.flatMap((item) => {
|
||||
if (seen.has(item.href)) {
|
||||
return [];
|
||||
}
|
||||
seen.add(item.href);
|
||||
return [{ href: item.href, label: item.label, icon: item.icon } satisfies WorkspaceFabItem];
|
||||
});
|
||||
});
|
||||
const primaryBottomNavigation = $derived(
|
||||
[
|
||||
...(visibleDashboardItem ? [visibleDashboardItem] : []),
|
||||
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
|
||||
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
|
||||
...visibleWorkingDocumentItems.slice(0, 2)
|
||||
]
|
||||
);
|
||||
const workingDocumentsActive = $derived(
|
||||
visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname))
|
||||
);
|
||||
const visibleBaseSearchItems = $derived(
|
||||
baseSearchItems.filter((item) => {
|
||||
if (item.href === '/') return canOpenDashboard;
|
||||
if (item.href === '/mixes') return canOpenMixMaster;
|
||||
if (item.href === '/mixes/new') return canCreateMixWorksheet;
|
||||
if (item.href === '/mix-calculator') return canOpenMixCalculator;
|
||||
if (item.href === '/product-costing') return sessionCanOpenProductCosting($clientSession);
|
||||
if (item.href === '/editor') return canOpenEditor;
|
||||
if (item.href === '/ingredients') return canOpenEditor;
|
||||
if (item.href === '/reporting') return sessionCanOpenReporting($clientSession);
|
||||
if (item.href === '/settings') return canOpenSettings;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
const searchItems = $derived([...visibleBaseSearchItems, ...seededSearchItems]);
|
||||
const showWorkspaceBoot = $derived(!isRootRoute && (!$sessionHydrated || !bootDelayDone));
|
||||
const showDesktopSidebar = $derived(!showBottomNav);
|
||||
|
||||
function restoreSidebarState() {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSidebarState() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarOpen));
|
||||
} catch {
|
||||
// Storage failures should not block shell interactions.
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch(query = '') {
|
||||
searchQuery = query;
|
||||
searchOpen = true;
|
||||
searchFocusRequest += 1;
|
||||
appsFabOpen = false;
|
||||
userMenuOpen = false;
|
||||
navOpen = false;
|
||||
}
|
||||
|
||||
function syncViewport() {
|
||||
showBottomNav = window.innerWidth <= 1180;
|
||||
|
||||
if (!showBottomNav) {
|
||||
navOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearchItem(item: SearchItem) {
|
||||
searchOpen = false;
|
||||
searchQuery = '';
|
||||
await goto(item.href);
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
appsFabOpen = false;
|
||||
userMenuOpen = false;
|
||||
navOpen = false;
|
||||
await goto('/settings');
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
if ($clientSession?.role === 'internal') {
|
||||
await api.internalLogout();
|
||||
} else {
|
||||
await api.clientLogout();
|
||||
}
|
||||
} catch {
|
||||
// Clearing the local session remains the safe fallback.
|
||||
} finally {
|
||||
clientSession.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const searchState = $derived(filterSearchItems(searchItems, searchQuery, PALETTE_RESULT_LIMIT));
|
||||
|
||||
$effect(() => {
|
||||
page.url.pathname;
|
||||
appsFabOpen = false;
|
||||
userMenuOpen = false;
|
||||
searchOpen = false;
|
||||
searchQuery = '';
|
||||
navOpen = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!sidebarStateReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
sidebarOpen;
|
||||
persistSidebarState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const hydrated = $sessionHydrated;
|
||||
const sessionKey = buildSessionKey($clientSession);
|
||||
|
||||
if (!hydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionKey) {
|
||||
isRestoringSession = false;
|
||||
restoredSessionKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredSessionKey === sessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoredSessionKey = sessionKey;
|
||||
isRestoringSession = true;
|
||||
|
||||
// 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) => {
|
||||
restoredSessionKey = `${session.role}:${session.email}:${session.user_id ?? ''}`;
|
||||
clientSession.set(session);
|
||||
return invalidateAll();
|
||||
})
|
||||
.catch(() => {
|
||||
restoredSessionKey = null;
|
||||
clientSession.clear();
|
||||
})
|
||||
.finally(() => {
|
||||
isRestoringSession = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Search items are seeded lazily — three list endpoints worth of
|
||||
// data only when the user actually opens the search, not on every login or
|
||||
// navigation. Subsequent opens hit the api.ts cache.
|
||||
$effect(() => {
|
||||
const hydrated = $sessionHydrated;
|
||||
const session = $clientSession;
|
||||
const sessionKey = buildSessionKey(session);
|
||||
const shouldSeed = searchOpen;
|
||||
|
||||
if (!hydrated || !session || !sessionKey) {
|
||||
seededSearchItems = [];
|
||||
seededSearchKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldSeed || seededSearchKey === sessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
seededSearchKey = sessionKey;
|
||||
|
||||
Promise.all([
|
||||
sessionCanOpenMixMaster(session) ? api.mixes() : Promise.resolve([]),
|
||||
featureFlags.mixCalculatorSessionHistory && sessionCanOpenMixCalculator(session)
|
||||
? api.mixCalculatorSessions()
|
||||
: Promise.resolve([])
|
||||
])
|
||||
.then(([mixes, sessions]) => {
|
||||
if (seededSearchKey !== sessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
seededSearchItems = [
|
||||
...mixes.map((mix) => ({
|
||||
href: `/mixes/${mix.id}`,
|
||||
label: mix.name,
|
||||
description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`,
|
||||
keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}`
|
||||
})),
|
||||
...sessions.map((savedSession) => ({
|
||||
href: `/mix-calculator/${savedSession.id}`,
|
||||
label: `${savedSession.session_number} · ${savedSession.product_name}`,
|
||||
description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`,
|
||||
keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}`
|
||||
}))
|
||||
];
|
||||
})
|
||||
.catch(() => {
|
||||
if (seededSearchKey === sessionKey) {
|
||||
seededSearchItems = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($sessionHydrated && !$clientSession && !isRootRoute) {
|
||||
goto('/', { replaceState: true });
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!$sessionHydrated || !$clientSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRouteAllowed || page.url.pathname === workspaceHomeHref) {
|
||||
return;
|
||||
}
|
||||
|
||||
goto(workspaceHomeHref, { replaceState: true });
|
||||
});
|
||||
|
||||
// Surface the release notes once per version per user, right after login.
|
||||
// hasSeenVersion keeps this to a single appearance: once dismissed (which
|
||||
// records the version), it won't return until the next version ships.
|
||||
$effect(() => {
|
||||
if (!$sessionHydrated || !$clientSession || !currentChangelog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userKey = buildSessionKey($clientSession);
|
||||
if (!userKey) {
|
||||
return;
|
||||
}
|
||||
if (whatsNewCheckedFor === userKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
whatsNewCheckedFor = userKey;
|
||||
if (!hasSeenVersion(userKey, currentChangelog.version)) {
|
||||
whatsNewOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
function dismissWhatsNew() {
|
||||
const userKey = buildSessionKey($clientSession);
|
||||
if (userKey && currentChangelog) {
|
||||
markVersionSeen(userKey, currentChangelog.version);
|
||||
}
|
||||
whatsNewOpen = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
sidebarOpen = restoreSidebarState();
|
||||
sidebarStateReady = true;
|
||||
|
||||
const bootTimer = window.setTimeout(() => {
|
||||
bootDelayDone = true;
|
||||
}, 1500);
|
||||
|
||||
syncViewport();
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTypingField =
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target?.isContentEditable;
|
||||
|
||||
if (canUseWorkspaceSearch && ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/'))) {
|
||||
event.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
searchOpen = false;
|
||||
appsFabOpen = false;
|
||||
userMenuOpen = false;
|
||||
navOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
window.addEventListener('resize', syncViewport);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(bootTimer);
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
window.removeEventListener('resize', syncViewport);
|
||||
};
|
||||
});
|
||||
|
||||
const userInitials = $derived(
|
||||
($clientSession?.name ?? '')
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.toUpperCase() || '?'
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{shellPageMeta.title} | Hunter Premium Produce</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !$clientSession}
|
||||
<div class="signed-out-shell">
|
||||
{#if isRootRoute}
|
||||
{@render children()}
|
||||
{:else if showWorkspaceBoot}
|
||||
<WorkspaceBootCard />
|
||||
{:else}
|
||||
<WorkspaceSignedOutCard />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class:sidebar-collapsed={!sidebarOpen && showDesktopSidebar} class="app-shell">
|
||||
{#if showDesktopSidebar}
|
||||
<ClientPrimaryRail
|
||||
collapsed={!sidebarOpen}
|
||||
currentPath={shellPathname}
|
||||
entries={navEntries}
|
||||
brandHref={workspaceHomeHref}
|
||||
footerItems={visibleFooterLinks}
|
||||
{appVersion}
|
||||
{currentYear}
|
||||
{canOpenSettings}
|
||||
onOpenSettings={openSettings}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
|
||||
<ClientTopbar
|
||||
sessionHydrated={$sessionHydrated}
|
||||
session={$clientSession}
|
||||
showSidebarToggle={!showBottomNav}
|
||||
{sidebarOpen}
|
||||
{userInitials}
|
||||
{userMenuOpen}
|
||||
{canUseWorkspaceSearch}
|
||||
bind:searchQuery={searchQuery}
|
||||
bind:searchOpen={searchOpen}
|
||||
searchFocusRequest={searchFocusRequest}
|
||||
filteredSearchItems={searchState.filteredItems}
|
||||
hiddenResultCount={searchState.hiddenResultCount}
|
||||
{canOpenSettings}
|
||||
onRunSearchItem={runSearchItem}
|
||||
onToggleSidebar={() => (sidebarOpen = !sidebarOpen)}
|
||||
onToggleUserMenu={() => {
|
||||
userMenuOpen = !userMenuOpen;
|
||||
appsFabOpen = false;
|
||||
}}
|
||||
onOpenSettings={openSettings}
|
||||
onSignOut={signOut}
|
||||
onShowWhatsNew={() => (whatsNewOpen = true)}
|
||||
/>
|
||||
|
||||
<main class="content">
|
||||
{#if !routeGuardPending}
|
||||
<WorkspacePageHeader
|
||||
category={shellPageMeta.category}
|
||||
title={shellPageMeta.title}
|
||||
icon={shellPageMeta.icon}
|
||||
/>
|
||||
{/if}
|
||||
<AuthGate
|
||||
blocked={routeGuardPending}
|
||||
label={isRestoringSession ? 'Checking Session' : 'Applying Access Rules'}
|
||||
title={isRestoringSession ? 'Restoring your client workspace.' : 'Routing you to an authorised page.'}
|
||||
detail={
|
||||
isRestoringSession
|
||||
? 'Refreshing the saved session before rendering workspace content.'
|
||||
: `The ${workspaceRole} role cannot open this route, so the workspace is redirecting before any page content mounts.`
|
||||
}
|
||||
>
|
||||
{@render children()}
|
||||
</AuthGate>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<WorkspaceAppsFab bind:open={appsFabOpen} items={fabItems} />
|
||||
</div>
|
||||
|
||||
<WorkspaceTabletNav
|
||||
bind:navOpen
|
||||
{showBottomNav}
|
||||
{primaryBottomNavigation}
|
||||
{visibleDashboardItem}
|
||||
{visibleMixCalculatorItem}
|
||||
{visibleProductCostingItem}
|
||||
{visibleThroughputItem}
|
||||
{visibleEditorItem}
|
||||
{visibleReportingItem}
|
||||
{visibleWorkingDocumentItems}
|
||||
{visibleFooterLinks}
|
||||
{orderingManageGroup}
|
||||
{orderingManageChildren}
|
||||
{orderingItem}
|
||||
{canManageOrdering}
|
||||
{canOpenCustomerOrdering}
|
||||
{canCreateMixWorksheet}
|
||||
{canCreateMixSession}
|
||||
{canOpenSettings}
|
||||
pagePath={page.url.pathname}
|
||||
onOpenSettings={openSettings}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
|
||||
{/if}
|
||||
|
||||
{#if $clientSession && whatsNewOpen && currentChangelog}
|
||||
<WhatsNewDialog entry={currentChangelog} onClose={dismissWhatsNew} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 252px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.app-shell.sidebar-collapsed {
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.signed-out-shell {
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.main-shell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.content {
|
||||
--content-padding: 1.34rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: var(--content-padding);
|
||||
overflow: auto;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.main-shell.bottom-nav-layout .content {
|
||||
padding-bottom: 7.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content {
|
||||
--content-padding: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1181px) {
|
||||
.bottom-nav-layout .content {
|
||||
padding-bottom: 1.34rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.content {
|
||||
--content-padding: 0.92rem;
|
||||
padding: 0.92rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,43 +9,146 @@
|
||||
</script>
|
||||
|
||||
{#if blocked}
|
||||
<section class="auth-gate-card">
|
||||
<p class="auth-gate-label">{label}</p>
|
||||
<h2>{title}</h2>
|
||||
<p>{detail}</p>
|
||||
</section>
|
||||
<div class="auth-gate-screen">
|
||||
<section class="auth-gate-card" role="status" aria-live="polite">
|
||||
<div class="auth-gate-loader" aria-hidden="true">
|
||||
<span class="ring"></span>
|
||||
<span class="ring ring-2"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
<p class="auth-gate-label">{label}</p>
|
||||
<h2>{title}</h2>
|
||||
<p class="auth-gate-detail">{detail}</p>
|
||||
</section>
|
||||
</div>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.auth-gate-screen {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-gate-card {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.35rem 1.4rem;
|
||||
width: min(26rem, 100%);
|
||||
padding: 2.1rem 2rem 2.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
border-radius: 1.25rem;
|
||||
background: var(--panel);
|
||||
text-align: center;
|
||||
box-shadow: 0 18px 50px -24px rgba(0, 0, 0, 0.4);
|
||||
animation: auth-gate-in 380ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
/* ── Animated loader ───────────────────────────────────────── */
|
||||
.auth-gate-loader {
|
||||
position: relative;
|
||||
width: 3.1rem;
|
||||
height: 3.1rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.auth-gate-loader .ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid transparent;
|
||||
border-top-color: var(--color-brand, #2f9e6f);
|
||||
border-right-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 45%, transparent);
|
||||
animation: auth-gate-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.auth-gate-loader .ring-2 {
|
||||
inset: 0.42rem;
|
||||
border-top-color: color-mix(in srgb, var(--color-brand, #2f9e6f) 60%, transparent);
|
||||
border-right-color: transparent;
|
||||
animation-duration: 1.25s;
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
.auth-gate-loader .dot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand, #2f9e6f);
|
||||
animation: auth-gate-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.auth-gate-label {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-gate-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.18rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.auth-gate-card p:last-child {
|
||||
.auth-gate-detail {
|
||||
margin: 0;
|
||||
max-width: 22rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@keyframes auth-gate-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes auth-gate-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes auth-gate-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.7);
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.auth-gate-card {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.auth-gate-loader .ring,
|
||||
.auth-gate-loader .ring-2 {
|
||||
animation-duration: 1.6s;
|
||||
}
|
||||
|
||||
.auth-gate-loader .dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -206,9 +206,7 @@
|
||||
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Client Access Control</p>
|
||||
<h2>Manage module permissions, feature flags, and audit history from one workspace.</h2>
|
||||
<p>Lean 101 admins and tenant superadmins use the same control surface, and every change lands in the audit log immediately.</p>
|
||||
<p class="page-intro-copy">Lean 101 admins and tenant superadmins use the same control surface, and every change lands in the audit log immediately.</p>
|
||||
</div>
|
||||
<span class="status-pill positive">Signed in as {accessManagerLabel}</span>
|
||||
</section>
|
||||
@@ -587,7 +585,6 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p,
|
||||
@@ -617,14 +614,12 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-intro h2 {
|
||||
margin: 0.35rem 0 0.45rem;
|
||||
max-width: 22ch;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
font-weight: 700;
|
||||
.page-intro-copy {
|
||||
max-width: 62ch;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.page-intro p:last-child,
|
||||
.page-intro-copy,
|
||||
.metric-card p,
|
||||
.card-toolbar p,
|
||||
.client-row span,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -103,7 +103,7 @@
|
||||
<td>
|
||||
<strong>{line.raw_material_name}</strong>
|
||||
</td>
|
||||
<td>{formatNumber(line.required_kg, 2)}kg</td>
|
||||
<td>{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Moon, Sun } from 'lucide-svelte';
|
||||
import { resolvedTheme, toggleTheme } from '$lib/theme';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
|
||||
const isDark = $derived($resolvedTheme === 'dark');
|
||||
const label = $derived(
|
||||
isDark ? 'Switch to light mode' : 'Switch to dark mode'
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="theme-toggle"
|
||||
type="button"
|
||||
onclick={toggleTheme}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
aria-label={label}
|
||||
use:tooltip={label}
|
||||
>
|
||||
{#if isDark}
|
||||
<Sun size={18} strokeWidth={1.75} />
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Sparkles } from 'lucide-svelte';
|
||||
import type { ChangelogEntry } from '$lib/changelog';
|
||||
import { changelog, type ChangelogEntry } from '$lib/changelog';
|
||||
|
||||
let { entry, onClose }: { entry: ChangelogEntry; onClose: () => void } = $props();
|
||||
|
||||
const releaseDate = $derived(
|
||||
new Date(`${entry.date}T00:00:00`).toLocaleDateString(undefined, {
|
||||
function formatDate(date: string): string {
|
||||
return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const releaseDate = $derived(formatDate(entry.date));
|
||||
|
||||
// Every release older than the one being shown, newest first, for the
|
||||
// "Read previous changes" accordion.
|
||||
const previousEntries = $derived(changelog.filter((item) => item.version !== entry.version));
|
||||
</script>
|
||||
|
||||
<div class="whats-new-backdrop" role="presentation" onclick={onClose}>
|
||||
@@ -42,6 +48,27 @@
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if previousEntries.length}
|
||||
<details class="whats-new-history">
|
||||
<summary>Read previous changes</summary>
|
||||
<ol class="history-list">
|
||||
{#each previousEntries as item (item.version)}
|
||||
<li class="history-entry">
|
||||
<div class="history-head">
|
||||
<span class="history-version">v{item.version}</span>
|
||||
<span class="history-date">{formatDate(item.date)}</span>
|
||||
</div>
|
||||
<ul class="history-highlights">
|
||||
{#each item.highlights as highlight}
|
||||
<li>{highlight}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<div class="whats-new-actions">
|
||||
<button class="whats-new-button" type="button" onclick={onClose}>Got it</button>
|
||||
</div>
|
||||
@@ -141,6 +168,102 @@
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.whats-new-history {
|
||||
border-top: 1px solid var(--color-divider);
|
||||
padding-top: 1.05rem;
|
||||
}
|
||||
|
||||
.whats-new-history > summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
color: var(--color-brand);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.whats-new-history > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.whats-new-history > summary::before {
|
||||
content: '';
|
||||
width: 0.46rem;
|
||||
height: 0.46rem;
|
||||
margin-right: 0.55rem;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.whats-new-history[open] > summary::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.whats-new-history > summary:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 1.05rem;
|
||||
margin: 1rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.history-version {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.history-highlights {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.history-highlights li {
|
||||
position: relative;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.history-highlights li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.15rem;
|
||||
width: 0.34rem;
|
||||
height: 0.34rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.whats-new-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { LayoutGrid } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export type WorkspaceFabItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: ComponentType;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
open = $bindable(false)
|
||||
}: {
|
||||
items: WorkspaceFabItem[];
|
||||
open?: boolean;
|
||||
} = $props();
|
||||
|
||||
let root = $state<HTMLElement | null>(null);
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!open || !root || root.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pointerdown', handlePointerDown);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handlePointerDown);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if items.length}
|
||||
<div class="apps-fab-wrap" bind:this={root}>
|
||||
{#if open}
|
||||
<div class="apps-panel" role="menu" aria-label="Quick access apps">
|
||||
{#each items as item (item.href)}
|
||||
<a href={item.href} class="apps-link" role="menuitem" onclick={close}>
|
||||
<span class="apps-link-icon" aria-hidden="true">
|
||||
<item.icon size={16} strokeWidth={2} />
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="apps-fab"
|
||||
aria-expanded={open}
|
||||
aria-label="Open app launcher"
|
||||
use:tooltip={{ label: 'Quickly access apps', placement: 'top' }}
|
||||
onclick={toggle}
|
||||
>
|
||||
<LayoutGrid size={20} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.apps-fab-wrap {
|
||||
position: fixed;
|
||||
right: max(1rem, env(safe-area-inset-right));
|
||||
bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
z-index: 46;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.apps-fab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
box-shadow: 0 20px 36px -22px color-mix(in srgb, var(--color-brand) 75%, transparent);
|
||||
cursor: pointer;
|
||||
transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.apps-fab:hover {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in srgb, var(--color-brand) 92%, black);
|
||||
box-shadow: 0 24px 44px -24px color-mix(in srgb, var(--color-brand) 78%, transparent);
|
||||
}
|
||||
|
||||
.apps-fab:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 24%, white);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.apps-panel {
|
||||
min-width: 15rem;
|
||||
display: grid;
|
||||
gap: 0.24rem;
|
||||
padding: 0.45rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--color-bg-elevated);
|
||||
box-shadow: 0 24px 48px -28px rgba(15, 23, 42, 0.32);
|
||||
}
|
||||
|
||||
.apps-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.72rem;
|
||||
padding: 0.8rem 0.82rem;
|
||||
border-radius: 0.82rem;
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.apps-link:hover {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.apps-link-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 0.65rem;
|
||||
background: color-mix(in srgb, var(--color-brand) 10%, var(--color-bg-surface));
|
||||
color: var(--color-brand);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.apps-fab-wrap {
|
||||
bottom: calc(max(0.8rem, env(safe-area-inset-bottom)) + 5.9rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { LoaderCircle } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<section class="locked-card loading-card workspace-boot-card" aria-live="polite">
|
||||
<div class="workspace-boot-orb" aria-hidden="true">
|
||||
<LoaderCircle size={26} strokeWidth={2.1} />
|
||||
</div>
|
||||
<p class="workspace-label">Checking Workspace</p>
|
||||
<h2>Restoring your client workspace.</h2>
|
||||
<p>Hold on while we reload your saved session and bring the app back into place.</p>
|
||||
<div class="workspace-boot-progress" aria-hidden="true">
|
||||
<span class="workspace-boot-progress-bar"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.locked-card {
|
||||
max-width: 42rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.workspace-boot-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
align-items: start;
|
||||
min-height: 14rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.45rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-boot-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand) 18%, transparent), transparent 42%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 4%, transparent), transparent 45%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.workspace-boot-card > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.workspace-boot-orb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.35rem;
|
||||
height: 3.35rem;
|
||||
border-radius: 1rem;
|
||||
background: color-mix(in srgb, var(--color-brand) 12%, var(--color-bg-surface));
|
||||
color: var(--color-brand);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-brand) 16%, transparent);
|
||||
}
|
||||
|
||||
.workspace-boot-orb :global(svg) {
|
||||
animation: workspace-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.workspace-boot-progress {
|
||||
width: min(16rem, 100%);
|
||||
height: 0.42rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-brand) 10%, var(--color-border));
|
||||
}
|
||||
|
||||
.workspace-boot-progress-bar {
|
||||
display: block;
|
||||
width: 38%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--color-brand), color-mix(in srgb, var(--color-brand) 62%, white));
|
||||
animation: workspace-progress 1.15s ease-in-out infinite;
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0.35rem;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
}
|
||||
|
||||
p:last-of-type {
|
||||
margin-top: 0.45rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes workspace-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes workspace-progress {
|
||||
0% { transform: translateX(-110%) scaleX(0.85); }
|
||||
55% { transform: translateX(135%) scaleX(1.05); }
|
||||
100% { transform: translateX(280%) scaleX(0.9); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workspace-boot-orb :global(svg),
|
||||
.workspace-boot-progress-bar {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<section class="locked-card loading-card signed-out-card">
|
||||
<p class="workspace-label">Checking Session</p>
|
||||
<h2>Returning to the client login screen.</h2>
|
||||
<p>Only authenticated client users can open workspace routes directly.</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.locked-card {
|
||||
max-width: 42rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.signed-out-card {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0.35rem;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
}
|
||||
|
||||
p:last-of-type {
|
||||
margin-top: 0.45rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,499 @@
|
||||
<script lang="ts">
|
||||
import { Calculator, LogOut, Menu, Plus, Settings } from 'lucide-svelte';
|
||||
|
||||
import type { FooterLink, NavGroup, NavItem } from '$lib/navigation/client-navigation';
|
||||
import { matchesRoute } from '$lib/navigation/client-navigation';
|
||||
|
||||
let {
|
||||
showBottomNav,
|
||||
navOpen = $bindable(false),
|
||||
primaryBottomNavigation,
|
||||
visibleDashboardItem,
|
||||
visibleMixCalculatorItem,
|
||||
visibleProductCostingItem,
|
||||
visibleThroughputItem,
|
||||
visibleEditorItem,
|
||||
visibleReportingItem,
|
||||
visibleWorkingDocumentItems,
|
||||
visibleFooterLinks,
|
||||
orderingManageGroup,
|
||||
orderingManageChildren,
|
||||
orderingItem,
|
||||
canManageOrdering,
|
||||
canOpenCustomerOrdering,
|
||||
canCreateMixWorksheet,
|
||||
canCreateMixSession,
|
||||
canOpenSettings,
|
||||
pagePath,
|
||||
onOpenSettings,
|
||||
onSignOut
|
||||
}: {
|
||||
showBottomNav: boolean;
|
||||
navOpen?: boolean;
|
||||
primaryBottomNavigation: NavItem[];
|
||||
visibleDashboardItem: NavItem | null;
|
||||
visibleMixCalculatorItem: NavItem | null;
|
||||
visibleProductCostingItem: NavItem | null;
|
||||
visibleThroughputItem: NavItem | null;
|
||||
visibleEditorItem: NavItem | null;
|
||||
visibleReportingItem: NavItem | null;
|
||||
visibleWorkingDocumentItems: NavItem[];
|
||||
visibleFooterLinks: FooterLink[];
|
||||
orderingManageGroup: NavGroup;
|
||||
orderingManageChildren: NavItem[];
|
||||
orderingItem: NavItem;
|
||||
canManageOrdering: boolean;
|
||||
canOpenCustomerOrdering: boolean;
|
||||
canCreateMixWorksheet: boolean;
|
||||
canCreateMixSession: boolean;
|
||||
canOpenSettings: boolean;
|
||||
pagePath: string;
|
||||
onOpenSettings: () => void | Promise<void>;
|
||||
onSignOut: () => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
function closeDrawer() {
|
||||
navOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showBottomNav}
|
||||
{#if navOpen}
|
||||
<button aria-label="Close navigation" class="nav-backdrop" type="button" onclick={closeDrawer}></button>
|
||||
{/if}
|
||||
|
||||
<nav class="bottom-nav" aria-label="Tablet navigation">
|
||||
{#each primaryBottomNavigation as item}
|
||||
{@const Icon = item.icon}
|
||||
<a class:active={matchesRoute(item.href, pagePath)} href={item.href}>
|
||||
<span class="bottom-nav-icon"><Icon size={18} strokeWidth={1.85} /></span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<button aria-expanded={navOpen} class:active={navOpen} type="button" onclick={() => (navOpen = !navOpen)}>
|
||||
<span class="bottom-nav-icon"><Menu size={18} strokeWidth={1.85} /></span>
|
||||
<span>More</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{#if navOpen}
|
||||
<div aria-label="Tablet navigation drawer" class="bottom-drawer" role="dialog" aria-modal="true">
|
||||
<div class="drawer-handle"></div>
|
||||
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
<p class="workspace-label">Workspace Drawer</p>
|
||||
<strong>Hunter Premium Produce</strong>
|
||||
</div>
|
||||
<button aria-label="Close drawer" class="nav-toggle" type="button" onclick={closeDrawer}>
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="drawer-grid">
|
||||
<nav class="drawer-section" aria-label="All workspace pages">
|
||||
{#if visibleDashboardItem}
|
||||
{@const Icon = visibleDashboardItem.icon}
|
||||
<a class:active={matchesRoute(visibleDashboardItem.href, pagePath)} href={visibleDashboardItem.href} onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleDashboardItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleMixCalculatorItem}
|
||||
{@const Icon = visibleMixCalculatorItem.icon}
|
||||
<a class:active={matchesRoute(visibleMixCalculatorItem.href, pagePath)} href={visibleMixCalculatorItem.href} onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleMixCalculatorItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleProductCostingItem}
|
||||
{@const Icon = visibleProductCostingItem.icon}
|
||||
<a class:active={matchesRoute(visibleProductCostingItem.href, pagePath)} href={visibleProductCostingItem.href} onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleProductCostingItem.label}</span>
|
||||
{#if visibleProductCostingItem.badge}<span class="drawer-badge">{visibleProductCostingItem.badge}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleThroughputItem}
|
||||
{@const Icon = visibleThroughputItem.icon}
|
||||
<a class:active={matchesRoute(visibleThroughputItem.href, pagePath)} href={visibleThroughputItem.href} onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleThroughputItem.label}</span>
|
||||
{#if visibleThroughputItem.badge}<span class="drawer-badge">{visibleThroughputItem.badge}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleEditorItem}
|
||||
{@const Icon = visibleEditorItem.icon}
|
||||
<a class:active={matchesRoute(visibleEditorItem.href, pagePath)} href={visibleEditorItem.href} onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleEditorItem.label}</span>
|
||||
{#if visibleEditorItem.badge}<span class="drawer-badge">{visibleEditorItem.badge}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleReportingItem}
|
||||
{@const Icon = visibleReportingItem.icon}
|
||||
<a class:active={matchesRoute(visibleReportingItem.href, pagePath)} href={visibleReportingItem.href} onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{visibleReportingItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if canManageOrdering}
|
||||
{@const GroupIcon = orderingManageGroup.icon}
|
||||
<a class:active={pagePath === '/ordering/manage'} href="/ordering/manage" onclick={closeDrawer}>
|
||||
<span class="nav-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{orderingManageGroup.label}</span>
|
||||
</a>
|
||||
<div class="drawer-sublist">
|
||||
{#each orderingManageChildren as child}
|
||||
{@const ChildIcon = child.icon}
|
||||
<a class:active={matchesRoute(child.href, pagePath, child.exact)} href={child.href} onclick={closeDrawer}>
|
||||
<span class="nav-icon"><ChildIcon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{child.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if canOpenCustomerOrdering}
|
||||
{@const Icon = orderingItem.icon}
|
||||
<a class:active={matchesRoute(orderingItem.href, pagePath)} href={orderingItem.href} onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{orderingItem.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if visibleWorkingDocumentItems.length}
|
||||
<div class="drawer-sublist" id="drawer-working-documents-nav">
|
||||
{#each visibleWorkingDocumentItems as item}
|
||||
{@const Icon = item.icon}
|
||||
<a class:active={matchesRoute(item.href, pagePath)} href={item.href} onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<div class="drawer-section drawer-actions">
|
||||
{#if canCreateMixWorksheet}
|
||||
<a href="/mixes/new" onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Plus size={18} strokeWidth={1.75} /></span>
|
||||
<span>Create mix worksheet</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if canCreateMixSession}
|
||||
<a href="/mix-calculator" onclick={closeDrawer}>
|
||||
<span class="nav-icon"><Calculator size={18} strokeWidth={1.75} /></span>
|
||||
<span>Create mix session</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if canOpenSettings}
|
||||
<button type="button" onclick={onOpenSettings}>
|
||||
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
|
||||
<span>Change settings</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" onclick={onSignOut}>
|
||||
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
{#each visibleFooterLinks as item}
|
||||
<a href={item.href} onclick={closeDrawer}>
|
||||
<span>{item.label}</span>
|
||||
<small>{item.shortLabel}</small>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.nav-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 48;
|
||||
display: block;
|
||||
border: none;
|
||||
background: rgba(11, 18, 14, 0.28);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
width: 2.05rem;
|
||||
height: 2.05rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.68rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-toggle span,
|
||||
.nav-toggle span::before,
|
||||
.nav-toggle span::after {
|
||||
width: 0.88rem;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 999px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.nav-toggle span {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-toggle span::before,
|
||||
.nav-toggle span::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.nav-toggle span::before {
|
||||
top: -0.28rem;
|
||||
}
|
||||
|
||||
.nav-toggle span::after {
|
||||
top: 0.28rem;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
border-radius: 0.55rem;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
}
|
||||
|
||||
.bottom-nav,
|
||||
.bottom-drawer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawer-sublist a {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
left: max(0.8rem, env(safe-area-inset-left));
|
||||
right: max(0.8rem, env(safe-area-inset-right));
|
||||
bottom: max(0.8rem, env(safe-area-inset-bottom));
|
||||
z-index: 45;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1.35rem;
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.bottom-nav a,
|
||||
.bottom-nav button {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.34rem;
|
||||
padding: 0.62rem 0.38rem;
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottom-nav a.active,
|
||||
.bottom-nav button.active {
|
||||
color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.78rem;
|
||||
color: var(--color-on-brand);
|
||||
background: var(--color-brand);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: 1.6rem 1.6rem 0 0;
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
width: 3.5rem;
|
||||
height: 0.34rem;
|
||||
margin: 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.drawer-header strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.drawer-grid {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.drawer-section a,
|
||||
.drawer-section button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.72rem;
|
||||
padding: 0.82rem 0.86rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.96rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer-section a.active {
|
||||
color: var(--color-brand-hover);
|
||||
background: color-mix(in srgb, var(--color-brand) 11%, var(--color-bg-surface));
|
||||
}
|
||||
|
||||
.drawer-badge {
|
||||
margin-left: auto;
|
||||
padding: 0.08rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-warning-tint);
|
||||
color: var(--color-warning-text);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-sublist {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.drawer-footer a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.82rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.96rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.drawer-footer small {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.drawer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.bottom-nav {
|
||||
left: max(0.55rem, env(safe-area-inset-left));
|
||||
right: max(0.55rem, env(safe-area-inset-right));
|
||||
bottom: max(0.55rem, env(safe-area-inset-bottom));
|
||||
gap: 0.32rem;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.bottom-nav a,
|
||||
.bottom-nav button {
|
||||
padding: 0.55rem 0.2rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.bottom-drawer {
|
||||
padding: 0.75rem 0.8rem calc(6.3rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SearchItem } from '$lib/navigation/client-navigation';
|
||||
import type { AppSession } from '$lib/session';
|
||||
|
||||
export const PALETTE_RESULT_LIMIT = 10;
|
||||
|
||||
export function buildSessionKey(session: AppSession | null | undefined): string | null {
|
||||
if (!session) return null;
|
||||
return `${session.role}:${session.email}:${session.user_id ?? ''}`;
|
||||
}
|
||||
|
||||
export function filterSearchItems(items: SearchItem[], query: string, limit = PALETTE_RESULT_LIMIT) {
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const matchingItems = items.filter((item) => {
|
||||
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
|
||||
return haystack.includes(trimmedQuery);
|
||||
});
|
||||
|
||||
const filteredItems = matchingItems.slice(0, limit);
|
||||
return {
|
||||
matchingItems,
|
||||
filteredItems,
|
||||
hiddenResultCount: matchingItems.length - filteredItems.length
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Check, ChevronDown, Plus, Search, X } from 'lucide-svelte';
|
||||
|
||||
// A category picker with search + explicit "create new". Typing only *searches*
|
||||
// — the committed value changes only when you pick an existing category or
|
||||
// deliberately choose "Create new category". This keeps spelling consistent and
|
||||
// makes creating a brand-new category an obvious, intentional action rather than
|
||||
// a side effect of typing. The menu is portaled to <body> so the ingredients
|
||||
// table's clipped (overflow:hidden) scroll container can never hide it.
|
||||
let {
|
||||
value = $bindable(''),
|
||||
options = [],
|
||||
placeholder = 'Category',
|
||||
inputId,
|
||||
disabled = false,
|
||||
ariaLabel = 'Category',
|
||||
oncreate
|
||||
}: {
|
||||
value?: string;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
inputId?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
/** Fired when the user deliberately creates a brand-new category, so the
|
||||
* parent can keep it available to every other row. */
|
||||
oncreate?: (category: string) => void;
|
||||
} = $props();
|
||||
|
||||
// `query` is the ephemeral search text; `value` is the committed category.
|
||||
let query = $state('');
|
||||
let open = $state(false);
|
||||
let highlighted = $state(0);
|
||||
let root = $state<HTMLDivElement | null>(null);
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
let menuStyle = $state('');
|
||||
|
||||
// The input shows the live search text while open, and the committed value when
|
||||
// closed — so an in-progress search never looks like it changed the field.
|
||||
const display = $derived(open ? query : value);
|
||||
|
||||
const trimmed = $derived(query.trim());
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = trimmed.toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((option) => option.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Offer "create" only when the typed text isn't already a category.
|
||||
const exactExists = $derived(options.some((option) => option.toLowerCase() === trimmed.toLowerCase()));
|
||||
const showCreate = $derived(trimmed.length > 0 && !exactExists);
|
||||
|
||||
// Selectable rows = filtered options, then the create row (when shown).
|
||||
const rowCount = $derived(filtered.length + (showCreate ? 1 : 0));
|
||||
const createIndex = $derived(showCreate ? filtered.length : -1);
|
||||
|
||||
function positionMenu() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
menuStyle = `top: ${rect.bottom + 4}px; left: ${rect.left}px; min-width: ${Math.max(rect.width, 220)}px;`;
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
if (disabled) return;
|
||||
query = value;
|
||||
open = true;
|
||||
// Highlight the create row when there's nothing to match, else the first option.
|
||||
highlighted = 0;
|
||||
await tick();
|
||||
positionMenu();
|
||||
inputEl?.select();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
open = false;
|
||||
highlighted = 0;
|
||||
}
|
||||
|
||||
function choose(option: string) {
|
||||
value = option;
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
const created = trimmed;
|
||||
value = created;
|
||||
oncreate?.(created);
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
value = '';
|
||||
query = '';
|
||||
closeMenu();
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
function commitHighlighted() {
|
||||
if (highlighted === createIndex) {
|
||||
createNew();
|
||||
} else if (highlighted >= 0 && highlighted < filtered.length) {
|
||||
choose(filtered[highlighted]);
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
query = (event.target as HTMLInputElement).value;
|
||||
open = true;
|
||||
highlighted = 0;
|
||||
positionMenu();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
openMenu();
|
||||
return;
|
||||
}
|
||||
highlighted = Math.min(highlighted + 1, rowCount - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
highlighted = Math.max(highlighted - 1, 0);
|
||||
} else if (event.key === 'Enter') {
|
||||
if (open && rowCount > 0) {
|
||||
event.preventDefault();
|
||||
commitHighlighted();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
if (open) {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
// The menu lives in <body> (portaled) and its rows use mousedown+preventDefault,
|
||||
// so a click on a row never blurs the input. Any real blur closes the menu and
|
||||
// discards the in-progress search (the committed value is untouched).
|
||||
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
// Move the menu to <body> so no ancestor's overflow/transform can clip it.
|
||||
function portal(node: HTMLElement) {
|
||||
if (typeof document !== 'undefined') document.body.appendChild(node);
|
||||
return {
|
||||
destroy() {
|
||||
node.parentNode?.removeChild(node);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Keep the portaled menu glued to the input while scrolling/resizing.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const handler = () => positionMenu();
|
||||
window.addEventListener('scroll', handler, true);
|
||||
window.addEventListener('resize', handler);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handler, true);
|
||||
window.removeEventListener('resize', handler);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="combo" bind:this={root} onfocusout={onFocusOut}>
|
||||
<span class="combo-icon" aria-hidden="true"><Search size={15} strokeWidth={2.2} /></span>
|
||||
<input
|
||||
id={inputId}
|
||||
bind:this={inputEl}
|
||||
class="combo-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
{placeholder}
|
||||
aria-label={ariaLabel}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
role="combobox"
|
||||
aria-controls={inputId ? `${inputId}-list` : undefined}
|
||||
value={display}
|
||||
{disabled}
|
||||
oninput={onInput}
|
||||
onfocus={openMenu}
|
||||
onkeydown={onKeydown}
|
||||
/>
|
||||
{#if value && !disabled}
|
||||
<button type="button" class="combo-clear" onmousedown={(e) => { e.preventDefault(); clear(); }} aria-label="Clear category">
|
||||
<X size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="combo-caret" aria-hidden="true"><ChevronDown size={15} strokeWidth={2.2} /></span>
|
||||
{/if}
|
||||
|
||||
{#if open && !disabled}
|
||||
<ul class="menu" use:portal id={inputId ? `${inputId}-list` : undefined} role="listbox" style={menuStyle}>
|
||||
{#if filtered.length}
|
||||
<li class="menu-label" aria-hidden="true">Categories</li>
|
||||
{#each filtered as option, i (option)}
|
||||
<li
|
||||
class="row"
|
||||
class:highlighted={i === highlighted}
|
||||
class:selected={option.toLowerCase() === value.toLowerCase()}
|
||||
role="option"
|
||||
aria-selected={option.toLowerCase() === value.toLowerCase()}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
choose(option);
|
||||
}}
|
||||
onmouseenter={() => (highlighted = i)}
|
||||
>
|
||||
<span class="row-label">{option}</span>
|
||||
{#if option.toLowerCase() === value.toLowerCase()}
|
||||
<span class="row-check" aria-hidden="true"><Check size={14} strokeWidth={2.6} /></span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if showCreate}
|
||||
<li
|
||||
class="row create"
|
||||
class:highlighted={highlighted === createIndex}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
createNew();
|
||||
}}
|
||||
onmouseenter={() => (highlighted = createIndex)}
|
||||
>
|
||||
<span class="create-icon" aria-hidden="true"><Plus size={15} strokeWidth={2.6} /></span>
|
||||
<span class="create-text">Create new category <strong>“{trimmed}”</strong></span>
|
||||
</li>
|
||||
{:else if filtered.length === 0}
|
||||
<li class="row empty">Start typing to add a category.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.combo {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.combo-icon {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
display: inline-flex;
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Match the editor's compact inputs (the parent's scoped `input` rule can't
|
||||
reach this child component). */
|
||||
.combo-input {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0.38rem 1.7rem 0.38rem 1.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.42rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.88rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.combo-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.combo-input:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.combo-input:focus-visible {
|
||||
outline: 3px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.combo-input:disabled {
|
||||
background: color-mix(in srgb, var(--color-bg-app) 70%, var(--color-bg-surface));
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.combo-caret {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
display: inline-flex;
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.combo-clear {
|
||||
position: absolute;
|
||||
right: 0.35rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.combo-clear:hover {
|
||||
background: var(--color-bg-app);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* The menu is portaled to <body>, so it can't rely on inherited layout — it
|
||||
positions itself fixed against the input's rect. */
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 400;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.55rem;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
padding: 0.3rem 0.55rem 0.2rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.42rem 0.55rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row.highlighted {
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.row.empty {
|
||||
color: var(--color-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-check {
|
||||
color: var(--color-brand);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The create action is deliberately prominent: a brand-tinted row with a + icon
|
||||
so "make a new category" reads as a distinct, intentional choice. */
|
||||
.row.create {
|
||||
margin-top: 0.15rem;
|
||||
border-top: 1px solid var(--color-divider);
|
||||
padding-top: 0.5rem;
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row.create.highlighted {
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.create-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.create-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.create-text strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,360 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import type { EditorChangeEvent } from '$lib/types';
|
||||
import { Clock, History, X } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
entityType,
|
||||
entityId,
|
||||
title,
|
||||
subtitle = '',
|
||||
onClose
|
||||
}: {
|
||||
entityType: 'mix' | 'ingredient';
|
||||
entityId: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let events = $state<EditorChangeEvent[] | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
events =
|
||||
entityType === 'mix'
|
||||
? await api.editorMixHistory(entityId)
|
||||
: await api.editorIngredientHistory(entityId);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Unable to load history';
|
||||
}
|
||||
});
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
created: 'Created',
|
||||
updated: 'Updated',
|
||||
formula_updated: 'Formula updated',
|
||||
ingredient_added: 'Ingredient added',
|
||||
ingredient_updated: 'Ingredient updated',
|
||||
ingredient_removed: 'Ingredient removed'
|
||||
};
|
||||
|
||||
function actionLabel(action: string) {
|
||||
return ACTION_LABELS[action] ?? action.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatWhen(value: string) {
|
||||
// Audit times are stored in UTC on the server (datetime.utcnow), serialized
|
||||
// without a timezone suffix. Parse the parts as UTC and let the browser
|
||||
// render them in the viewer's local time, so an edit made at midday in
|
||||
// Australia reads as midday rather than the raw 02:00 UTC value.
|
||||
const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/);
|
||||
if (!match) return value;
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
const date = new Date(
|
||||
Date.UTC(+year, +month - 1, +day, +hour, +minute, second ? +second : 0)
|
||||
);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('en-AU', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="history-backdrop" role="presentation" onclick={onClose}>
|
||||
<div
|
||||
class="history-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Change history for ${title}`}
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}}
|
||||
>
|
||||
<header class="history-head">
|
||||
<div class="history-title">
|
||||
<span class="kicker"><History size={14} strokeWidth={2.2} /> Change history</span>
|
||||
<h3>{title}</h3>
|
||||
{#if subtitle}<p class="subtitle">{subtitle}</p>{/if}
|
||||
</div>
|
||||
<button class="icon-close" type="button" onclick={onClose} aria-label="Close history">
|
||||
<X size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="history-body">
|
||||
{#if error}
|
||||
<p class="state error">{error}</p>
|
||||
{:else if events === null}
|
||||
<p class="state">Loading history…</p>
|
||||
{:else if events.length === 0}
|
||||
<div class="empty">
|
||||
<Clock size={22} strokeWidth={1.8} />
|
||||
<p>No changes recorded yet.</p>
|
||||
<span>Edits made from here on will appear in this list.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<ol class="timeline">
|
||||
{#each events as event (event.id)}
|
||||
<li class="event">
|
||||
<div class="event-head">
|
||||
<span class="badge">{actionLabel(event.action)}</span>
|
||||
<time>{formatWhen(event.created_at)}</time>
|
||||
</div>
|
||||
<p class="summary">{event.summary}</p>
|
||||
{#if event.changes.length}
|
||||
<ul class="deltas">
|
||||
{#each event.changes as delta}
|
||||
<li>
|
||||
<span class="delta-label">{delta.label}</span>
|
||||
<span class="delta-values">
|
||||
<span class="before">{delta.before ?? '—'}</span>
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
<span class="after">{delta.after ?? '—'}</span>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="actor">by {event.actor_name}{#if event.actor_role} · {event.actor_role}{/if}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.history-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(17, 24, 20, 0.52);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.history-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(620px, 100%);
|
||||
max-height: calc(100vh - 2rem);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--color-bg-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.history-title h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.icon-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-close:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.history-body {
|
||||
padding: 1rem 1.1rem 1.2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 1.5rem 0;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state.error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
font-weight: 650;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.empty span {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.event {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.event-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.event-head time {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.deltas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0.1rem 0 0;
|
||||
padding: 0.5rem 0.6rem;
|
||||
list-style: none;
|
||||
border-radius: 0.45rem;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.deltas li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.delta-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delta-values {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.before {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.after {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.actor {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { Calculator, Info, TriangleAlert, X } from 'lucide-svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { featureFlags } from '$lib/features';
|
||||
import { formatNumber } from '$lib/format';
|
||||
@@ -12,6 +13,7 @@
|
||||
MixCalculatorSession
|
||||
} from '$lib/types';
|
||||
import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte';
|
||||
import MixCalculatorMixPicker from './MixCalculatorMixPicker.svelte';
|
||||
|
||||
let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props();
|
||||
|
||||
@@ -51,6 +53,9 @@
|
||||
let batchSizeKg = $state(initialBatchSizeValue());
|
||||
let preparedByName = $state(initialPreparedByNameValue());
|
||||
let notes = $state(initialNotesValue());
|
||||
// Notes are optional and stay hidden behind "+ Add a note" until needed,
|
||||
// matching the throughput composer. Auto-open when a saved session has one.
|
||||
let showNote = $state(Boolean(initialSession?.notes));
|
||||
let preview = $state<MixCalculatorPreview | MixCalculatorSession | null>(initialPreviewValue());
|
||||
let formError = $state('');
|
||||
let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock mixes.');
|
||||
@@ -158,6 +163,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse the note field again, discarding anything typed.
|
||||
function dismissNote() {
|
||||
notes = '';
|
||||
showNote = false;
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
clientName = '';
|
||||
productId = 0;
|
||||
@@ -165,6 +176,7 @@
|
||||
batchSizeKg = '';
|
||||
preparedByName = $clientSession?.name ?? '';
|
||||
notes = '';
|
||||
showNote = false;
|
||||
preview = null;
|
||||
formError = '';
|
||||
formHint = 'Select a mix date and prepared by name, then choose a client to unlock mixes.';
|
||||
@@ -287,13 +299,13 @@
|
||||
{/if}
|
||||
|
||||
<section class="editor-grid">
|
||||
<article class="form-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>Session Inputs</h3>
|
||||
<p>Batch size drives the scale factor. Total bags are derived from the selected mix unit size.</p>
|
||||
<article class="composer">
|
||||
<div class="composer-head">
|
||||
<div class="composer-title">
|
||||
<span class="composer-icon"><Calculator size={18} strokeWidth={2.2} /></span>
|
||||
<h2>Mix calculator</h2>
|
||||
</div>
|
||||
{#if selectedProduct}
|
||||
{#if selectedProduct && selectedProduct.unit_size_kg > 0}
|
||||
<div class="product-pill">
|
||||
<strong>{selectedProduct.unit_size_kg}kg</strong>
|
||||
<span>{selectedProduct.unit_of_measure}</span>
|
||||
@@ -301,12 +313,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="composer-body">
|
||||
{#if formError}
|
||||
<p class="message error">{formError}</p>
|
||||
<p class="message error"><TriangleAlert size={16} strokeWidth={2.2} /> <span>{formError}</span></p>
|
||||
{/if}
|
||||
|
||||
{#if !formError && formHint}
|
||||
<p class="message hint">{formHint}</p>
|
||||
<p class="message hint"><Info size={16} strokeWidth={2.2} /> <span>{formHint}</span></p>
|
||||
{/if}
|
||||
|
||||
<div class="field-grid">
|
||||
@@ -332,29 +345,39 @@
|
||||
|
||||
<label>
|
||||
<span>Mix Name</span>
|
||||
<select
|
||||
bind:value={productId}
|
||||
<MixCalculatorMixPicker
|
||||
products={filteredProducts}
|
||||
bind:productId
|
||||
disabled={!canEdit || !clientName || !filteredProducts.length}
|
||||
title={!clientName ? 'Select a client first.' : !filteredProducts.length ? 'No mixes are available for the selected client.' : 'Select a mix.'}
|
||||
>
|
||||
<option value={0}>Select a mix</option>
|
||||
{#each filteredProducts as product}
|
||||
<option value={product.product_id}>
|
||||
{product.product_name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
inputId="mix-calculator-mix"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Batch size (kg)</span>
|
||||
<input bind:value={batchSizeKg} disabled={!canEdit} inputmode="decimal" min="0" placeholder="Batch size" type="number" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="full-width">
|
||||
<span>Notes</span>
|
||||
<textarea bind:value={notes} disabled={!canEdit} placeholder="Optional production notes or shift context" rows="4"></textarea>
|
||||
</label>
|
||||
<div class="note-area">
|
||||
{#if showNote}
|
||||
<div class="note-field">
|
||||
<textarea
|
||||
class="note-input"
|
||||
bind:value={notes}
|
||||
disabled={!canEdit}
|
||||
placeholder="Note (optional) — production notes or shift context"
|
||||
rows="3"
|
||||
></textarea>
|
||||
{#if canEdit}
|
||||
<button type="button" class="note-dismiss" title="Remove note" aria-label="Remove note" onclick={dismissNote}>
|
||||
<X size={16} strokeWidth={2.4} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if canEdit}
|
||||
<button type="button" class="link-button" onclick={() => (showNote = true)}>+ Add a note</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if canEdit && selectedProduct}
|
||||
@@ -371,12 +394,13 @@
|
||||
<span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span>
|
||||
</button>
|
||||
|
||||
<button class="danger-button" disabled={previewLoading} type="button" onclick={clearForm}>
|
||||
<button class="secondary-button" disabled={previewLoading} type="button" onclick={clearForm}>
|
||||
<span class="button-icon" style="--button-icon-url: url('/icons/trash.svg');" aria-hidden="true"></span>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<MixCalculatorResultsPanel
|
||||
@@ -400,13 +424,12 @@
|
||||
|
||||
<style>
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #7d8d84;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -425,9 +448,8 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-header p,
|
||||
.calculation-note span {
|
||||
color: var(--muted);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
@@ -443,21 +465,68 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-card,
|
||||
.locked-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
/* The composer mirrors the throughput "Add a packing run" surface: a quiet
|
||||
brand-tinted gradient panel that reads as a distinct, on-brand workspace.
|
||||
Theme tokens keep it correct in dark mode. */
|
||||
.composer {
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
|
||||
border-radius: 0.9rem;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-brand) 7%, var(--color-bg-surface)),
|
||||
color-mix(in srgb, var(--color-brand) 4%, var(--color-bg-surface))
|
||||
);
|
||||
/* Native selects open within the viewport, so the card can stay open for
|
||||
a clean rounded edge without clipping anything. */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.form-card,
|
||||
.locked-card {
|
||||
padding: 1.2rem;
|
||||
.composer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem 1.4rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 1.3rem 1.45rem 1.05rem;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Brand-tinted badge echoing the throughput overview's section icon. */
|
||||
.composer-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.composer-title h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.25rem, 2vw, 1.7rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.composer-body {
|
||||
padding: 0 1.45rem 1.4rem;
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
max-width: 42rem;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.locked-card h2 {
|
||||
@@ -465,33 +534,28 @@
|
||||
font-size: clamp(1.7rem, 3vw, 2.1rem);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Quick brand-tinted chip echoing the selected mix's unit, matching the pill
|
||||
language used across the app. */
|
||||
.product-pill {
|
||||
display: grid;
|
||||
gap: 0.14rem;
|
||||
padding: 0.72rem 0.82rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--panel-soft);
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.78rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.product-pill strong {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.product-pill span {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
@@ -510,67 +574,155 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
/* Optional note, revealed by "+ Add a note" — mirrors the throughput composer. */
|
||||
.note-area {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
.link-button {
|
||||
padding: 0.25rem 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--color-brand);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.note-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.note-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.note-dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
||||
border-radius: 0.8rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
background-color 140ms ease;
|
||||
}
|
||||
|
||||
.note-dismiss:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.note-dismiss:focus-visible {
|
||||
outline: 3px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* White fields on the green tint, with brand-tinted borders and a 48px touch
|
||||
target — the same input language as the throughput composer. */
|
||||
.composer input,
|
||||
.composer select,
|
||||
.composer textarea {
|
||||
width: 100%;
|
||||
padding: 0.78rem 0.82rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-input-bg);
|
||||
color: var(--text);
|
||||
min-height: 48px;
|
||||
padding: 0.62rem 0.78rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
||||
border-radius: 0.8rem;
|
||||
font-size: 0.98rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
.composer input::placeholder,
|
||||
.composer textarea::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
.composer input:focus-visible,
|
||||
.composer select:focus-visible,
|
||||
.composer textarea:focus-visible {
|
||||
outline: 3px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
.composer textarea {
|
||||
min-height: 6.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* The Mix Name select stays locked until a client is chosen; make that state
|
||||
unmistakable rather than looking like an empty, clickable field. */
|
||||
.composer input:disabled,
|
||||
.composer select:disabled,
|
||||
.composer textarea:disabled {
|
||||
background: color-mix(in srgb, var(--color-bg-app) 70%, var(--color-bg-surface));
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.calculation-note {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.92rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-row);
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.72rem 0.9rem;
|
||||
border-radius: 0.7rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message :global(svg) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #fff1f0;
|
||||
color: #b2463f;
|
||||
background: color-mix(in srgb, var(--color-error) 12%, var(--color-bg-surface));
|
||||
color: var(--color-error);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Readable on-brand hint: a white-ish surface with a green info icon, instead
|
||||
of low-contrast grey that washed out against the composer's tint. */
|
||||
.message.hint {
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--color-brand) 16%, var(--color-bg-app));
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 26%, var(--color-border));
|
||||
}
|
||||
|
||||
.message.hint :global(svg) {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
@@ -578,15 +730,14 @@
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.danger-button {
|
||||
.secondary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.78rem 0.96rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-control);
|
||||
border: 1px solid var(--color-border);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -597,26 +748,29 @@
|
||||
.primary-button {
|
||||
border: none;
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
/* Composer buttons take the throughput composer's chunkier 48px shape. */
|
||||
.composer .action-row .primary-button,
|
||||
.composer .action-row .secondary-button {
|
||||
min-height: 48px;
|
||||
padding: 0.62rem 1.28rem;
|
||||
border-radius: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: #fff;
|
||||
color: #304038;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
background: #c63d32;
|
||||
color: #fff;
|
||||
border-color: #c63d32;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: #126a33;
|
||||
background: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
.primary-button:active:not(:disabled) {
|
||||
background: #0f5a2b;
|
||||
background: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
.secondary-button:hover:not(:disabled) {
|
||||
@@ -624,14 +778,8 @@
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.danger-button:hover:not(:disabled) {
|
||||
background: #b2352b;
|
||||
border-color: #b2352b;
|
||||
}
|
||||
|
||||
.primary-button:focus-visible,
|
||||
.secondary-button:focus-visible,
|
||||
.danger-button:focus-visible {
|
||||
.secondary-button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -658,10 +806,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
<script lang="ts">
|
||||
import type { MixCalculatorProductOption } from '$lib/types';
|
||||
import { Search, X, Check } from 'lucide-svelte';
|
||||
|
||||
// Searchable Mix Name picker for the Mix Calculator. Mirrors the throughput
|
||||
// product search (type to filter, arrow/enter to choose) but keys on the
|
||||
// mix's representative product id. The client is chosen separately, so the
|
||||
// `products` passed in are already narrowed to that client.
|
||||
let {
|
||||
products = [],
|
||||
productId = $bindable(0),
|
||||
disabled = false,
|
||||
inputId = 'mix-calculator-mix'
|
||||
}: {
|
||||
products?: MixCalculatorProductOption[];
|
||||
productId?: number;
|
||||
disabled?: boolean;
|
||||
inputId?: string;
|
||||
} = $props();
|
||||
|
||||
let query = $state('');
|
||||
let open = $state(false);
|
||||
let highlighted = $state(-1);
|
||||
let focused = $state(false);
|
||||
let root = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function label(product: MixCalculatorProductOption): string {
|
||||
return product.product_name;
|
||||
}
|
||||
|
||||
const selected = $derived(
|
||||
productId ? products.find((p) => p.product_id === productId) ?? null : null
|
||||
);
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return products;
|
||||
return products.filter((product) => product.product_name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
// Clear the text box when the selection is cleared from outside (e.g. when the
|
||||
// client changes and the previously chosen mix no longer applies).
|
||||
$effect(() => {
|
||||
if (!productId && !focused) {
|
||||
query = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Reflect a selection set from outside so the box shows the chosen mix.
|
||||
$effect(() => {
|
||||
if (productId && !focused) {
|
||||
const match = products.find((p) => p.product_id === productId);
|
||||
if (match) query = label(match);
|
||||
}
|
||||
});
|
||||
|
||||
function choose(product: MixCalculatorProductOption) {
|
||||
productId = product.product_id;
|
||||
query = label(product);
|
||||
open = false;
|
||||
highlighted = -1;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
productId = 0;
|
||||
query = '';
|
||||
open = false;
|
||||
highlighted = -1;
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
query = (event.target as HTMLInputElement).value;
|
||||
productId = 0;
|
||||
open = true;
|
||||
highlighted = filtered.length ? 0 : -1;
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
open = true;
|
||||
highlighted = Math.min(highlighted + 1, filtered.length - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
highlighted = Math.max(highlighted - 1, 0);
|
||||
} else if (event.key === 'Enter') {
|
||||
if (open && highlighted >= 0 && highlighted < filtered.length) {
|
||||
event.preventDefault();
|
||||
choose(filtered[highlighted]);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
open = false;
|
||||
highlighted = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
focused = false;
|
||||
open = false;
|
||||
highlighted = -1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}>
|
||||
<div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}>
|
||||
<span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span>
|
||||
<input
|
||||
id={inputId}
|
||||
class="combo-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Search mix name…"
|
||||
value={query}
|
||||
{disabled}
|
||||
aria-autocomplete="list"
|
||||
oninput={onInput}
|
||||
onfocus={() => (open = true)}
|
||||
onkeydown={onKeydown}
|
||||
/>
|
||||
{#if productId}
|
||||
<button type="button" class="combo-clear" onclick={clear} aria-label="Clear mix">
|
||||
<X size={15} strokeWidth={2.4} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if open && !disabled}
|
||||
<ul class="options" id={`${inputId}-list`} role="listbox">
|
||||
{#if filtered.length === 0}
|
||||
<li class="option empty">No mixes match.</li>
|
||||
{:else}
|
||||
{#each filtered.slice(0, 50) as product, i (product.product_id)}
|
||||
<li
|
||||
class="option"
|
||||
class:highlighted={i === highlighted}
|
||||
class:selected={product.product_id === productId}
|
||||
role="option"
|
||||
aria-selected={product.product_id === productId}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
choose(product);
|
||||
}}
|
||||
onmouseenter={() => (highlighted = i)}
|
||||
>
|
||||
<span class="option-name">{product.product_name}</span>
|
||||
<span class="option-meta">
|
||||
{#if product.unit_size_kg > 0}
|
||||
<span class="option-unit">{product.unit_size_kg}kg {product.unit_of_measure}</span>
|
||||
{:else}
|
||||
<span class="option-tag">Formula only</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if product.product_id === productId}
|
||||
<span class="option-check" aria-hidden="true"><Check size={15} strokeWidth={2.6} /></span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{#if filtered.length > 50}
|
||||
<li class="option more">
|
||||
Showing first 50 of {filtered.length} — keep typing to narrow.
|
||||
</li>
|
||||
{/if}
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.combo {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.combo-icon {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
display: inline-flex;
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Self-contained input styling so the picker matches the composer's fields
|
||||
(Svelte scopes the parent's `.composer input` rule to the parent's own
|
||||
markup, so it can't reach this child component's input). */
|
||||
.combo-input {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0.62rem 2rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
||||
border-radius: 0.8rem;
|
||||
font-size: 0.98rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
.combo-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
.combo-input:focus-visible {
|
||||
outline: 3px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
.combo-input:disabled {
|
||||
background: color-mix(in srgb, var(--color-bg-app) 70%, var(--color-bg-surface));
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.combo-clear {
|
||||
position: absolute;
|
||||
right: 0.45rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.combo-clear:hover {
|
||||
background: var(--color-bg-app);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.options {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 0.45rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.option.highlighted {
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
.option.selected {
|
||||
font-weight: 650;
|
||||
}
|
||||
.option.empty,
|
||||
.option.more {
|
||||
color: var(--color-text-muted);
|
||||
cursor: default;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.option-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.option-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.option-unit {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.option-tag {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-app);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.option-check {
|
||||
color: var(--color-brand);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Download, Printer } from 'lucide-svelte';
|
||||
import { ArrowUpDown, Download, Printer } from 'lucide-svelte';
|
||||
import { formatDate, formatNumber } from '$lib/format';
|
||||
import type { MixCalculatorPreview, MixCalculatorSession } from '$lib/types';
|
||||
|
||||
@@ -15,21 +15,55 @@
|
||||
onDownloadPdf?: (() => void) | null;
|
||||
} = $props();
|
||||
|
||||
// ── Ingredient sorting ──────────────────────────────────────────
|
||||
// Default to the backend's category grouping (ingredients ordered by their
|
||||
// manually-assigned category). Clicking a header toggles direction or switches
|
||||
// column. Required kg starts descending; category and name start ascending.
|
||||
type LineSortKey = 'category' | 'raw_material_name' | 'required_kg';
|
||||
let sortKey = $state<LineSortKey>('category');
|
||||
let sortDir = $state<'asc' | 'desc'>('asc');
|
||||
|
||||
function toggleSort(key: LineSortKey) {
|
||||
if (sortKey === key) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
return;
|
||||
}
|
||||
sortKey = key;
|
||||
sortDir = key === 'required_kg' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
function ariaSort(key: LineSortKey): 'ascending' | 'descending' | 'none' {
|
||||
if (sortKey !== key) return 'none';
|
||||
return sortDir === 'asc' ? 'ascending' : 'descending';
|
||||
}
|
||||
|
||||
const sortedLines = $derived.by(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...(preview?.lines ?? [])].sort((a, b) => {
|
||||
let result: number;
|
||||
if (sortKey === 'required_kg') {
|
||||
result = (a.required_kg ?? 0) - (b.required_kg ?? 0);
|
||||
} else if (sortKey === 'category') {
|
||||
// The backend orders lines by category and renumbers sort_order to match,
|
||||
// so sorting on it reproduces the category grouping.
|
||||
result = (a.sort_order ?? 0) - (b.sort_order ?? 0);
|
||||
} else {
|
||||
result = a.raw_material_name.localeCompare(b.raw_material_name, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return result * dir;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<article class="result-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>Calculated Output</h3>
|
||||
<p>{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}</p>
|
||||
</div>
|
||||
{#if sessionNumber}
|
||||
{#if sessionNumber}
|
||||
<div class="result-toolbar">
|
||||
<div class="session-chip">
|
||||
<span>Session</span>
|
||||
<strong>{sessionNumber}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if preview}
|
||||
<div class="metric-row">
|
||||
@@ -81,17 +115,51 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raw material</th>
|
||||
<th>Required kg</th>
|
||||
<th aria-sort={ariaSort('category')}>
|
||||
<button
|
||||
type="button"
|
||||
class="sort-head"
|
||||
class:active={sortKey === 'category'}
|
||||
onclick={() => toggleSort('category')}
|
||||
>
|
||||
<span>Category</span>
|
||||
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
|
||||
</button>
|
||||
</th>
|
||||
<th aria-sort={ariaSort('raw_material_name')}>
|
||||
<button
|
||||
type="button"
|
||||
class="sort-head"
|
||||
class:active={sortKey === 'raw_material_name'}
|
||||
onclick={() => toggleSort('raw_material_name')}
|
||||
>
|
||||
<span>Raw material</span>
|
||||
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
|
||||
</button>
|
||||
</th>
|
||||
<th aria-sort={ariaSort('required_kg')}>
|
||||
<button
|
||||
type="button"
|
||||
class="sort-head"
|
||||
class:active={sortKey === 'required_kg'}
|
||||
onclick={() => toggleSort('required_kg')}
|
||||
>
|
||||
<span>Required kg</span>
|
||||
<ArrowUpDown size={13} strokeWidth={2.1} aria-hidden="true" />
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each preview.lines as line}
|
||||
{#each sortedLines as line}
|
||||
<tr>
|
||||
<td data-label="Category">
|
||||
<span class="category-cell">{line.category || '—'}</span>
|
||||
</td>
|
||||
<td data-label="Raw material">
|
||||
<strong>{line.raw_material_name}</strong>
|
||||
</td>
|
||||
<td data-label="Required kg">{formatNumber(line.required_kg, 2)}kg</td>
|
||||
<td data-label="Required kg">{formatNumber(line.required_kg, line.rounding_decimals ?? 2)}kg</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -135,55 +203,50 @@
|
||||
</article>
|
||||
|
||||
<style>
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-header p,
|
||||
.metric-card p,
|
||||
.summary-grid span,
|
||||
.empty-state span {
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.result-card,
|
||||
.metric-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.result-card {
|
||||
padding: 1.2rem;
|
||||
border-radius: var(--radius-panel);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
.metric-card {
|
||||
border-radius: var(--radius-row);
|
||||
}
|
||||
|
||||
.result-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-chip {
|
||||
display: grid;
|
||||
gap: 0.14rem;
|
||||
padding: 0.72rem 0.82rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--panel-soft);
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.78rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.session-chip span {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
@@ -205,7 +268,7 @@
|
||||
|
||||
.metric-card span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
@@ -220,9 +283,10 @@
|
||||
gap: 0.45rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.92rem;
|
||||
border-radius: 0.65rem;
|
||||
background: #fdf6e9;
|
||||
color: #8a5a00;
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
border-radius: var(--radius-row);
|
||||
background: var(--color-warning-tint);
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
@@ -232,9 +296,9 @@
|
||||
|
||||
.summary-grid div {
|
||||
padding: 0.88rem 0.92rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-row);
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.summary-grid span {
|
||||
@@ -267,17 +331,58 @@
|
||||
td {
|
||||
padding: 0.9rem 0.85rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.category-cell {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* Clickable header: inherits the th look, adds a sort affordance. */
|
||||
.sort-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.sort-head :global(svg) {
|
||||
opacity: 0.45;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.sort-head:hover,
|
||||
.sort-head.active {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sort-head.active :global(svg) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sort-head:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-brand) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
display: inline-flex;
|
||||
@@ -285,8 +390,8 @@
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.78rem 0.96rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-control);
|
||||
border: 1px solid var(--color-border);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -297,20 +402,20 @@
|
||||
.primary-button {
|
||||
border: none;
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: #fff;
|
||||
color: #304038;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: #126a33;
|
||||
background: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
.primary-button:active:not(:disabled) {
|
||||
background: #0f5a2b;
|
||||
background: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
.secondary-button:hover:not(:disabled) {
|
||||
@@ -335,7 +440,7 @@
|
||||
gap: 0;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.empty-shimmer-metrics {
|
||||
@@ -343,8 +448,8 @@
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.shimmer-metric {
|
||||
@@ -352,9 +457,9 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.85rem;
|
||||
background: var(--panel);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.empty-state-copy {
|
||||
@@ -364,14 +469,14 @@
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--color-bg-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.empty-state-copy strong {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.empty-state-copy span {
|
||||
@@ -407,7 +512,7 @@
|
||||
.empty-shimmer-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel-soft);
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
|
||||
.shimmer-row {
|
||||
@@ -416,7 +521,7 @@
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.78rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.shimmer-row:last-child {
|
||||
@@ -429,7 +534,7 @@
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-border) 25%,
|
||||
color-mix(in srgb, var(--color-border) 40%, white) 50%,
|
||||
color-mix(in srgb, var(--color-border) 45%, var(--color-bg-surface)) 50%,
|
||||
var(--color-border) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
@@ -452,10 +557,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -479,14 +580,14 @@
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-soft);
|
||||
background: var(--color-bg-app);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
tbody td:last-child {
|
||||
@@ -497,7 +598,7 @@
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
margin-bottom: 0.24rem;
|
||||
color: var(--muted);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
|
||||
@@ -299,13 +299,7 @@
|
||||
<a href="/">Return to sign-in</a>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">{savedMix ? 'Edit Mix' : 'New Mix'}</p>
|
||||
<h2>{savedMix ? `Editing ${savedMix.name}` : 'Create a new costing worksheet'}</h2>
|
||||
<p>Use ingredient rows like a spreadsheet, with live costing based on market value, waste, and unit conversion.</p>
|
||||
</div>
|
||||
|
||||
<section class="page-intro page-actions">
|
||||
<div class="intro-actions">
|
||||
<a class="secondary-button" href="/mixes">Back to table</a>
|
||||
<button class="primary-button" type="button" onclick={saveMix} disabled={isSaving}>
|
||||
@@ -584,15 +578,13 @@
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.locked-card h2,
|
||||
.page-intro h2 {
|
||||
.locked-card h2 {
|
||||
margin: 0.3rem 0 0.4rem;
|
||||
font-size: clamp(1.56rem, 3vw, 2.02rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.locked-card p:last-of-type,
|
||||
.page-intro p:last-child,
|
||||
.metric-card p,
|
||||
.summary-card span,
|
||||
.factor-list span,
|
||||
@@ -619,6 +611,10 @@
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
|
||||
.secondary-rail-layout-content > :global(*) {
|
||||
flex: 1 0 auto;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { ChevronDown, LogOut, Settings } from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
|
||||
import {
|
||||
groupHasActiveChild,
|
||||
@@ -13,6 +15,7 @@
|
||||
let {
|
||||
brandHref,
|
||||
currentPath,
|
||||
collapsed = false,
|
||||
entries,
|
||||
footerItems,
|
||||
appVersion,
|
||||
@@ -23,6 +26,7 @@
|
||||
}: {
|
||||
brandHref: string;
|
||||
currentPath: string;
|
||||
collapsed?: boolean;
|
||||
entries: NavEntry[];
|
||||
footerItems: FooterLink[];
|
||||
appVersion: string;
|
||||
@@ -68,66 +72,204 @@
|
||||
return null;
|
||||
});
|
||||
|
||||
// Open the active group once each time it changes. Because this only fires on
|
||||
// a *change* of activeGroupId, a user who manually closes the group they're
|
||||
// standing in won't have it reopened under them.
|
||||
// Open the active group once each time it changes, collapsing any other open
|
||||
// group so only one family is ever expanded (accordion). Because this only
|
||||
// fires on a *change* of activeGroupId, a user who manually closes the group
|
||||
// they're standing in won't have it reopened under them.
|
||||
$effect(() => {
|
||||
const id = activeGroupId;
|
||||
if (id && lastAutoExpanded !== id) {
|
||||
if (!openGroups[id]) {
|
||||
openGroups[id] = true;
|
||||
if (id) {
|
||||
if (lastAutoExpanded !== id) {
|
||||
openGroups = { [id]: true };
|
||||
persistOpenState();
|
||||
lastAutoExpanded = id;
|
||||
}
|
||||
lastAutoExpanded = id;
|
||||
} else if (lastAutoExpanded !== null) {
|
||||
// Just landed on a standalone module (Dashboard, Throughput, Reporting)
|
||||
// from inside a family. Collapse the open family so the rail tidies itself,
|
||||
// and forget the last auto-expanded group so returning to it — say
|
||||
// Throughput → Order Management — fires the expand again. The
|
||||
// `lastAutoExpanded !== null` guard makes this a one-shot per transition, so
|
||||
// a group the user manually opens while on a standalone page stays open.
|
||||
openGroups = {};
|
||||
persistOpenState();
|
||||
lastAutoExpanded = null;
|
||||
}
|
||||
});
|
||||
|
||||
const isOpen = (id: string) => openGroups[id] ?? false;
|
||||
|
||||
// Accordion: opening a group collapses every other group; closing just shuts
|
||||
// the one. So expanding Order Management folds an already-open Operations away.
|
||||
function toggleGroup(id: string) {
|
||||
openGroups[id] = !isOpen(id);
|
||||
openGroups = isOpen(id) ? {} : { [id]: true };
|
||||
persistOpenState();
|
||||
}
|
||||
|
||||
const moduleCount = $derived.by(() =>
|
||||
entries.reduce((count, entry) => count + (entry.kind === 'item' ? 1 : entry.group.children.length), 0)
|
||||
);
|
||||
// Expand a group without ever collapsing it. Used by a linkable group header
|
||||
// (Order Management) so clicking the label reveals its submenu the same way a
|
||||
// toggle group does — the route may not change (you're already on its page),
|
||||
// so the auto-expand effect can't be relied on to open it. The chevron remains
|
||||
// the way to collapse the family.
|
||||
function openGroup(id: string) {
|
||||
if (isOpen(id)) return;
|
||||
openGroups = { [id]: true };
|
||||
persistOpenState();
|
||||
}
|
||||
|
||||
// ── Third-level submenus (e.g. Integrations → Xero) ─────────────
|
||||
// Tracked independently of the top-level accordion: a nested submenu can be
|
||||
// open at the same time as its parent group, and toggling a top-level family
|
||||
// must not wipe a sibling's nested state. Keyed by "<groupId>:<childHref>".
|
||||
const SUB_STORAGE_KEY = 'hsf:nav:open-subgroups';
|
||||
|
||||
function restoreSubState(): Record<string, boolean> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
return JSON.parse(window.sessionStorage.getItem(SUB_STORAGE_KEY) ?? '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
let openSubGroups = $state<Record<string, boolean>>(restoreSubState());
|
||||
let lastAutoExpandedSub = $state<string | null>(null);
|
||||
let logoutConfirmOpen = $state(false);
|
||||
|
||||
function persistSubState() {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.sessionStorage.setItem(SUB_STORAGE_KEY, JSON.stringify(openSubGroups));
|
||||
} catch {
|
||||
// Private-mode storage failures shouldn't break navigation.
|
||||
}
|
||||
}
|
||||
|
||||
const subKey = (groupId: string, child: NavItem) => `${groupId}:${child.href}`;
|
||||
|
||||
/** True when a child row owns a nested submenu whose grandchild is active. */
|
||||
function subGroupActive(child: NavItem) {
|
||||
return child.children?.some((g) => matchesRoute(g.href, currentPath, g.exact)) ?? false;
|
||||
}
|
||||
|
||||
const activeSubKey = $derived.by(() => {
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'group') continue;
|
||||
for (const child of entry.group.children) {
|
||||
if (child.children?.length && subGroupActive(child)) {
|
||||
return subKey(entry.group.id, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Auto-open the submenu holding the current page, once per change. `untrack`
|
||||
// reads the current map without making it a dependency — otherwise writing it
|
||||
// back would retrigger this effect endlessly.
|
||||
$effect(() => {
|
||||
const key = activeSubKey;
|
||||
if (key) {
|
||||
if (lastAutoExpandedSub !== key) {
|
||||
openSubGroups = { ...untrack(() => openSubGroups), [key]: true };
|
||||
persistSubState();
|
||||
lastAutoExpandedSub = key;
|
||||
}
|
||||
} else {
|
||||
lastAutoExpandedSub = null;
|
||||
}
|
||||
});
|
||||
|
||||
const isSubOpen = (key: string) => openSubGroups[key] ?? false;
|
||||
|
||||
function toggleSubGroup(key: string) {
|
||||
openSubGroups = { ...openSubGroups, [key]: !isSubOpen(key) };
|
||||
persistSubState();
|
||||
}
|
||||
|
||||
// Reveal a nested submenu without collapsing it, mirroring openGroup for the
|
||||
// third level: clicking the Integrations label opens its connected-systems
|
||||
// list even when the route doesn't change.
|
||||
function openSubGroup(key: string) {
|
||||
if (isSubOpen(key)) return;
|
||||
openSubGroups = { ...openSubGroups, [key]: true };
|
||||
persistSubState();
|
||||
}
|
||||
|
||||
function handleLogoutAction() {
|
||||
if (collapsed) {
|
||||
logoutConfirmOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
onSignOut();
|
||||
}
|
||||
|
||||
function closeLogoutConfirm() {
|
||||
logoutConfirmOpen = false;
|
||||
}
|
||||
|
||||
function confirmSignOut() {
|
||||
logoutConfirmOpen = false;
|
||||
onSignOut();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet leafLink(item: NavItem, showIcon: boolean)}
|
||||
{@const Icon = item.icon}
|
||||
<a class="rail-row" class:active={matchesRoute(item.href, currentPath)} href={item.href}>
|
||||
<a
|
||||
class="rail-row"
|
||||
class:active={matchesRoute(item.href, currentPath, item.exact)}
|
||||
class:icon-only={collapsed}
|
||||
href={item.href}
|
||||
use:tooltip={collapsed ? { label: item.label, placement: 'right' } : ''}
|
||||
>
|
||||
{#if showIcon && Icon}
|
||||
<span class="rail-icon"><Icon size={18} strokeWidth={1.75} /></span>
|
||||
{/if}
|
||||
<span class="rail-text">{item.label}</span>
|
||||
{#if item.badge}<span class="rail-badge">{item.badge}</span>{/if}
|
||||
{#if !collapsed}
|
||||
<span class="rail-text">{item.label}</span>
|
||||
{#if item.badge}<span class="rail-badge">{item.badge}</span>{/if}
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet actionRow(label: string, Icon: ComponentType, active: boolean, onSelect: () => void)}
|
||||
{@const RowIcon = Icon}
|
||||
<button type="button" class="rail-row" class:active onclick={onSelect}>
|
||||
<button
|
||||
type="button"
|
||||
class="rail-row"
|
||||
class:active
|
||||
class:icon-only={collapsed}
|
||||
onclick={onSelect}
|
||||
use:tooltip={collapsed ? { label, placement: 'right' } : ''}
|
||||
>
|
||||
<span class="rail-icon"><RowIcon size={18} strokeWidth={1.75} /></span>
|
||||
<span class="rail-text">{label}</span>
|
||||
{#if !collapsed}
|
||||
<span class="rail-text">{label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<aside class="sidebar">
|
||||
<aside class:collapsed={collapsed} class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href={brandHref}>
|
||||
<span class="brand-kicker">Hunter App</span>
|
||||
<span class="brand-wordmark">Hunter Premium Produce</span>
|
||||
<span class="brand-subtitle">Operations workspace</span>
|
||||
{#if !collapsed}
|
||||
<span class="brand-kicker">Hunter App</span>
|
||||
<span class="brand-wordmark">Hunter Premium Produce</span>
|
||||
<span class="brand-subtitle">Operations workspace</span>
|
||||
{:else}
|
||||
<span class="brand-mini">HP</span>
|
||||
{/if}
|
||||
</a>
|
||||
<span class="module-pill">{moduleCount} modules</span>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-body">
|
||||
<div class="rail-scroll">
|
||||
<div class="rail-section-head">
|
||||
<p class="rail-section-label">Modules</p>
|
||||
<span class="rail-section-count">{moduleCount}</span>
|
||||
{#if !collapsed}
|
||||
<p class="rail-section-label">Modules</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="rail-nav" aria-label="Workspace navigation">
|
||||
@@ -139,32 +281,113 @@
|
||||
{@const GroupIcon = group.icon}
|
||||
{@const groupActive = groupHasActiveChild(group, currentPath)}
|
||||
{@const open = isOpen(group.id)}
|
||||
<div class="rail-group">
|
||||
<button
|
||||
type="button"
|
||||
class="rail-row rail-group-toggle"
|
||||
class:within-active={groupActive && !open}
|
||||
aria-expanded={open}
|
||||
onclick={() => toggleGroup(group.id)}
|
||||
{@const headerHref = group.href ?? group.children[0]?.href}
|
||||
{#if collapsed}
|
||||
<a
|
||||
class="rail-row icon-only"
|
||||
class:active={groupActive}
|
||||
href={headerHref}
|
||||
use:tooltip={{ label: group.label, placement: 'right' }}
|
||||
>
|
||||
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
|
||||
<span class="rail-text">{group.label}</span>
|
||||
<span class="rail-group-meta">
|
||||
<span class="rail-group-count">{group.children.length}</span>
|
||||
<span class="rail-chevron" class:open aria-hidden="true">
|
||||
<ChevronDown size={15} strokeWidth={2} />
|
||||
</a>
|
||||
{:else}
|
||||
<div class="rail-group">
|
||||
{#if headerHref}
|
||||
<!-- Every family header both navigates and reveals its submenu:
|
||||
the label goes to the family's landing route (Order Management
|
||||
→ its queue; Operations → its first tool) and opens the child
|
||||
list, while the chevron toggles independently. The header never
|
||||
takes the full active pill — that belongs to the matching child
|
||||
row — it only gets the subtle within-active emphasis when
|
||||
collapsed. -->
|
||||
<div class="rail-group-head" class:within-active={groupActive && !open}>
|
||||
<a
|
||||
class="rail-row rail-group-link"
|
||||
href={headerHref}
|
||||
onclick={() => openGroup(group.id)}
|
||||
>
|
||||
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
|
||||
<span class="rail-text">{group.label}</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="rail-chevron-btn"
|
||||
aria-expanded={open}
|
||||
aria-label={`${open ? 'Collapse' : 'Expand'} ${group.label}`}
|
||||
onclick={() => toggleGroup(group.id)}
|
||||
>
|
||||
<span class="rail-chevron" class:open aria-hidden="true">
|
||||
<ChevronDown size={15} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="rail-row rail-group-toggle"
|
||||
class:within-active={groupActive && !open}
|
||||
aria-expanded={open}
|
||||
onclick={() => toggleGroup(group.id)}
|
||||
>
|
||||
<span class="rail-icon"><GroupIcon size={18} strokeWidth={1.75} /></span>
|
||||
<span class="rail-text">{group.label}</span>
|
||||
<span class="rail-group-meta">
|
||||
<span class="rail-chevron" class:open aria-hidden="true">
|
||||
<ChevronDown size={15} strokeWidth={2} />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if open}
|
||||
<div class="rail-children">
|
||||
{#each group.children as child}
|
||||
{@render leafLink(child, false)}
|
||||
{#if child.children?.length}
|
||||
{@const key = subKey(group.id, child)}
|
||||
{@const subOpen = isSubOpen(key)}
|
||||
{@const subActive = subGroupActive(child)}
|
||||
<!-- Third layer: child row links to its own page; chevron
|
||||
reveals the nested submenu (e.g. Integrations → Xero). -->
|
||||
{@const ChildIcon = child.icon}
|
||||
<div class="rail-group-head rail-subgroup-head" class:within-active={subActive && !subOpen}>
|
||||
<a
|
||||
class="rail-row rail-group-link"
|
||||
class:active={matchesRoute(child.href, currentPath, child.exact)}
|
||||
href={child.href}
|
||||
onclick={() => openSubGroup(key)}
|
||||
>
|
||||
<span class="rail-icon"><ChildIcon size={18} strokeWidth={1.75} /></span>
|
||||
<span class="rail-text">{child.label}</span>
|
||||
{#if child.badge}<span class="rail-badge">{child.badge}</span>{/if}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="rail-chevron-btn"
|
||||
aria-expanded={subOpen}
|
||||
aria-label={`${subOpen ? 'Collapse' : 'Expand'} ${child.label}`}
|
||||
onclick={() => toggleSubGroup(key)}
|
||||
>
|
||||
<span class="rail-chevron" class:open={subOpen} aria-hidden="true">
|
||||
<ChevronDown size={15} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{#if subOpen}
|
||||
<div class="rail-children rail-subchildren">
|
||||
{#each child.children as grandchild}
|
||||
{@render leafLink(grandchild, true)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{@render leafLink(child, true)}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -179,29 +402,52 @@
|
||||
{#if canOpenSettings}
|
||||
{@render actionRow('Settings', Settings, currentPath.startsWith('/settings'), onOpenSettings)}
|
||||
{/if}
|
||||
{@render actionRow('Logout', LogOut, false, onSignOut)}
|
||||
{@render actionRow('Logout', LogOut, false, handleLogoutAction)}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-meta-foot">
|
||||
<div class="sidebar-meta-top">
|
||||
<span class="version-pill">
|
||||
<span class="meta-label">Build</span>
|
||||
<span>{appVersion}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="sidebar-meta-bottom">
|
||||
<small>© {currentYear} Hunter Premium Produce</small>
|
||||
<div class="powered-by">
|
||||
<span>Powered by</span>
|
||||
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
|
||||
<strong>Lean 101</strong>
|
||||
{#if !collapsed}
|
||||
<div class="sidebar-meta-foot">
|
||||
<div class="sidebar-meta-top">
|
||||
<span class="version-pill">
|
||||
<span class="meta-label">Build</span>
|
||||
<span>{appVersion}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="sidebar-meta-bottom">
|
||||
<small>© {currentYear} Hunter Premium Produce</small>
|
||||
<div class="powered-by">
|
||||
<span>Powered by</span>
|
||||
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
|
||||
<strong>Lean 101</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{#if logoutConfirmOpen}
|
||||
<div class="logout-dialog-backdrop" aria-hidden="true" onclick={closeLogoutConfirm}></div>
|
||||
<div
|
||||
class="logout-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="logout-dialog-title"
|
||||
aria-describedby="logout-dialog-description"
|
||||
>
|
||||
<div class="logout-dialog-copy">
|
||||
<p class="logout-dialog-kicker">Confirm Logout</p>
|
||||
<h2 id="logout-dialog-title">Log out of the workspace?</h2>
|
||||
<p id="logout-dialog-description">Your current client session will be closed and you will return to sign-in.</p>
|
||||
</div>
|
||||
<div class="logout-dialog-actions">
|
||||
<button type="button" class="logout-dialog-cancel" onclick={closeLogoutConfirm}>Cancel</button>
|
||||
<button type="button" class="logout-dialog-confirm" onclick={confirmSignOut}>Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Monochrome rail with a blue selected pill. Colours come from the --sidebar-*
|
||||
tokens, which are overridden in dark mode (see theme.css) so the rail themes
|
||||
@@ -219,6 +465,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
align-items: center;
|
||||
padding: 1rem 0.5rem 0.85rem;
|
||||
}
|
||||
|
||||
.rail-section-label {
|
||||
margin: 0;
|
||||
color: var(--sidebar-text-muted);
|
||||
@@ -255,20 +506,6 @@
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.rail-section-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.6rem;
|
||||
height: 1.35rem;
|
||||
padding: 0 0.42rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
|
||||
color: var(--sidebar-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -287,6 +524,20 @@
|
||||
padding: 0.08rem 0 0.1rem;
|
||||
}
|
||||
|
||||
.brand-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--sidebar-active-bg);
|
||||
color: var(--sidebar-active-text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.brand-kicker {
|
||||
color: var(--sidebar-text-muted);
|
||||
font-size: 0.66rem;
|
||||
@@ -309,26 +560,16 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.module-pill {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.34rem 0.58rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--sidebar-active-bg) 10%, transparent);
|
||||
color: var(--sidebar-active-bg);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Navigation rows ─────────────────────────────────────────── */
|
||||
.rail-nav {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .rail-nav {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.rail-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -407,6 +648,16 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rail-row.icon-only {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
width: 3rem;
|
||||
min-width: 3rem;
|
||||
min-height: 3rem;
|
||||
padding: 0;
|
||||
border-radius: 0.95rem;
|
||||
}
|
||||
|
||||
.rail-row.active .rail-badge {
|
||||
border-color: color-mix(in srgb, var(--sidebar-active-text) 26%, transparent);
|
||||
color: var(--sidebar-active-text);
|
||||
@@ -425,32 +676,91 @@
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.rail-group-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.35rem;
|
||||
height: 1.2rem;
|
||||
padding: 0 0.32rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--sidebar-text-strong) 6%, transparent);
|
||||
color: var(--sidebar-text-muted);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rail-group-toggle.within-active {
|
||||
color: var(--sidebar-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rail-group-toggle.within-active .rail-group-count {
|
||||
.rail-group-toggle.within-active .rail-icon,
|
||||
.rail-group-toggle.within-active .rail-chevron {
|
||||
color: var(--sidebar-text-strong);
|
||||
}
|
||||
|
||||
.rail-group-toggle.within-active .rail-icon,
|
||||
.rail-group-toggle.within-active .rail-chevron {
|
||||
/* ── Linkable group header (label links, chevron toggles) ────── */
|
||||
/* The label and chevron stay separate click targets (navigate vs toggle) but
|
||||
share one hover pill on the wrapper, so the whole header — icon, title,
|
||||
chevron — lights up as a single button instead of two halves. */
|
||||
.rail-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-radius: 0.8rem;
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
|
||||
.rail-group-head:hover {
|
||||
background: var(--sidebar-hover);
|
||||
}
|
||||
|
||||
.rail-group-head:hover .rail-group-link,
|
||||
.rail-group-head:hover .rail-icon,
|
||||
.rail-group-head:hover .rail-chevron {
|
||||
color: var(--sidebar-text-strong);
|
||||
}
|
||||
|
||||
/* Let the wrapper own the hover background; the inner targets stay transparent
|
||||
so they don't paint a second, mismatched pill on top. */
|
||||
.rail-group-head .rail-group-link:hover,
|
||||
.rail-group-head .rail-chevron-btn:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Category headers carry the lighter second-level row treatment (size, weight,
|
||||
radius); only the leading icon and chevron mark them as parents. Standalone
|
||||
top-level destinations (Dashboard, Throughput) keep the larger base row. */
|
||||
.rail-group-head .rail-group-link,
|
||||
.rail-group-toggle {
|
||||
min-height: 2.45rem;
|
||||
padding: 0.48rem 0.62rem 0.48rem 0.72rem;
|
||||
font-size: 0.88rem;
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
|
||||
.rail-group-head .rail-group-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail-group-head.within-active .rail-group-link {
|
||||
color: var(--sidebar-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rail-group-head.within-active .rail-group-link .rail-icon {
|
||||
color: var(--sidebar-text-strong);
|
||||
}
|
||||
|
||||
.rail-chevron-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
flex-shrink: 0;
|
||||
min-height: 2.45rem;
|
||||
padding: 0 0.58rem;
|
||||
border: none;
|
||||
border-radius: 0.8rem;
|
||||
background: transparent;
|
||||
color: var(--sidebar-icon);
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.rail-chevron-btn:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: var(--sidebar-text-strong);
|
||||
}
|
||||
|
||||
.rail-group-head.within-active .rail-chevron-btn {
|
||||
color: var(--sidebar-text-strong);
|
||||
}
|
||||
|
||||
@@ -493,6 +803,23 @@
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
|
||||
/* Third level: nest the submenu a little deeper than its parent row, with its
|
||||
own guide line, so the hierarchy reads as Group › Section › Item. */
|
||||
.rail-subgroup-head {
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
|
||||
.rail-subchildren {
|
||||
margin-left: 0.5rem;
|
||||
padding-left: 0.95rem;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.rail-subchildren .rail-row {
|
||||
min-height: 2.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@keyframes rail-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -522,6 +849,24 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-body,
|
||||
.sidebar.collapsed .rail-scroll,
|
||||
.sidebar.collapsed .sidebar-meta {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .rail-section-head {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
min-height: 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .brand-row,
|
||||
.sidebar.collapsed .sidebar-meta,
|
||||
.sidebar.collapsed .sidebar-meta-foot {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-meta-foot {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
@@ -531,14 +876,20 @@
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.sidebar-meta-top,
|
||||
.sidebar-meta-bottom {
|
||||
.sidebar-meta-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.sidebar-meta-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.sidebar-meta-foot small {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
@@ -590,4 +941,91 @@
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logout-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 39;
|
||||
background: rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.logout-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 40;
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--color-bg-elevated);
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.logout-dialog-copy {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.logout-dialog-kicker {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logout-dialog-copy h2,
|
||||
.logout-dialog-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logout-dialog-copy h2 {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.logout-dialog-copy p:last-child {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logout-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.logout-dialog-cancel,
|
||||
.logout-dialog-confirm {
|
||||
min-height: 2.7rem;
|
||||
padding: 0.65rem 0.95rem;
|
||||
border-radius: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout-dialog-cancel {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.logout-dialog-confirm {
|
||||
border: 1px solid transparent;
|
||||
background: var(--color-brand);
|
||||
color: var(--color-on-brand);
|
||||
}
|
||||
|
||||
.logout-dialog-cancel:hover {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.logout-dialog-confirm:hover {
|
||||
background: var(--color-brand-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { Settings } from 'lucide-svelte';
|
||||
import { PanelLeft, Settings, Sparkles } from 'lucide-svelte';
|
||||
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import WorkspaceSearchTrigger from '$lib/components/navigation/WorkspaceSearchTrigger.svelte';
|
||||
import WorkspaceSearchField from '$lib/components/navigation/WorkspaceSearchField.svelte';
|
||||
import type { SearchItem } from '$lib/navigation/client-navigation';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import type { AppSession } from '$lib/session';
|
||||
import type { Crumb } from '$lib/navigation/client-navigation';
|
||||
|
||||
let {
|
||||
breadcrumbs,
|
||||
title,
|
||||
sessionHydrated,
|
||||
session,
|
||||
showSidebarToggle,
|
||||
sidebarOpen,
|
||||
userInitials,
|
||||
userMenuOpen,
|
||||
canUseWorkspaceSearch,
|
||||
searchQuery = $bindable(''),
|
||||
searchOpen = $bindable(false),
|
||||
searchFocusRequest,
|
||||
filteredSearchItems,
|
||||
hiddenResultCount,
|
||||
canOpenSettings,
|
||||
onOpenPalette,
|
||||
onRunSearchItem,
|
||||
onToggleSidebar,
|
||||
onToggleUserMenu,
|
||||
onOpenSettings,
|
||||
onSignOut
|
||||
onSignOut,
|
||||
onShowWhatsNew
|
||||
}: {
|
||||
breadcrumbs: Crumb[];
|
||||
title: string;
|
||||
sessionHydrated: boolean;
|
||||
session: AppSession | null;
|
||||
showSidebarToggle: boolean;
|
||||
sidebarOpen: boolean;
|
||||
userInitials: string;
|
||||
userMenuOpen: boolean;
|
||||
canUseWorkspaceSearch: boolean;
|
||||
searchQuery?: string;
|
||||
searchOpen?: boolean;
|
||||
searchFocusRequest: number;
|
||||
filteredSearchItems: SearchItem[];
|
||||
hiddenResultCount: number;
|
||||
canOpenSettings: boolean;
|
||||
onOpenPalette: () => void;
|
||||
onRunSearchItem: (item: SearchItem) => void | Promise<void>;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleUserMenu: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onSignOut: () => void;
|
||||
onShowWhatsNew: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="topbar-start">
|
||||
{#if showSidebarToggle}
|
||||
<button
|
||||
class="sidebar-toggle"
|
||||
type="button"
|
||||
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
aria-pressed={sidebarOpen}
|
||||
onclick={onToggleSidebar}
|
||||
use:tooltip={sidebarOpen ? 'Hide menu' : 'Show menu'}
|
||||
>
|
||||
<PanelLeft size={17} strokeWidth={1.9} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<a class="topbar-brand" href="/" aria-label="Hunter Premium Produce home">
|
||||
<img src="/logo-hsf.png" alt="Hunter Premium Produce" />
|
||||
<img src="/hunter-logo-sidebar.png" alt="Hunter Premium Produce" />
|
||||
</a>
|
||||
<div class="topbar-copy">
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
{#each breadcrumbs as crumb, index}
|
||||
{#if index > 0}<span class="breadcrumb-sep" aria-hidden="true">/</span>{/if}
|
||||
{#if crumb.href && index < breadcrumbs.length - 1}
|
||||
<a href={crumb.href}>{crumb.label}</a>
|
||||
{:else}
|
||||
<span aria-current={index === breadcrumbs.length - 1 ? 'page' : undefined}>{crumb.label}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if canUseWorkspaceSearch}
|
||||
<div class="topbar-middle">
|
||||
<WorkspaceSearchTrigger className="topbar-search" onClick={onOpenPalette} />
|
||||
<WorkspaceSearchField
|
||||
bind:query={searchQuery}
|
||||
bind:open={searchOpen}
|
||||
focusRequest={searchFocusRequest}
|
||||
filteredSearchItems={filteredSearchItems}
|
||||
{hiddenResultCount}
|
||||
className="topbar-search"
|
||||
onRunSearchItem={onRunSearchItem}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="topbar-middle"></div>
|
||||
{/if}
|
||||
|
||||
<div class="topbar-actions">
|
||||
<button
|
||||
class="whats-new-toggle"
|
||||
type="button"
|
||||
onclick={onShowWhatsNew}
|
||||
aria-label="What's new"
|
||||
use:tooltip={"What's new — latest updates and changes"}
|
||||
>
|
||||
<Sparkles size={18} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<div class="menu-wrap user-menu-wrap">
|
||||
@@ -122,7 +155,7 @@
|
||||
<style>
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(20rem, 36rem) 1fr;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(20rem, 3fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.72rem 1.2rem;
|
||||
@@ -141,7 +174,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding-right: 0.9rem;
|
||||
/* Extra right padding gives the scaled-up logo room before the divider. */
|
||||
padding-right: 1.6rem;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -149,62 +183,80 @@
|
||||
height: 2rem;
|
||||
width: auto;
|
||||
display: block;
|
||||
/* Zoom the wide logo lockup in so the wordmark is legible, without growing
|
||||
the header: transform scales the visual only, leaving the 2rem layout box
|
||||
(and therefore the topbar height) untouched. Anchored left so it grows
|
||||
rightward from the topbar's left padding edge. */
|
||||
transform: scale(1.45);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.topbar-copy h1 {
|
||||
margin: 0.12rem 0 0;
|
||||
font-size: 1.34rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
.sidebar-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.32rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.7rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.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: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
.sidebar-toggle:hover {
|
||||
background: var(--panel-soft);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.topbar-middle {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* Fill the grid track; justify-self:center would collapse this to the
|
||||
field's content width and clip the placeholder. */
|
||||
justify-self: stretch;
|
||||
padding-left: 0.9rem;
|
||||
}
|
||||
|
||||
:global(.topbar-search) {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
|
||||
/* ~half the topbar on a large laptop; centered within its track. */
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.68rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
/* Never let the What's-new / theme toggles wrap above the user button. */
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* Matches the ThemeToggle button so the two sit as a pair. */
|
||||
.whats-new-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.82rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.whats-new-toggle:hover {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
@@ -390,6 +442,9 @@
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
/* Drop the search to its own row early: with the 252px sidebar present, a
|
||||
laptop's content width is already tight well above the sidebar's own
|
||||
collapse point, so keep the top row to brand + actions only. */
|
||||
@media (max-width: 1180px) {
|
||||
.topbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -404,6 +459,17 @@
|
||||
|
||||
.topbar-middle {
|
||||
grid-area: middle;
|
||||
/* Span the full row instead of shrinking to the field's content width. */
|
||||
justify-self: stretch;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* On its own row the field gets the whole width, which is too much — keep it
|
||||
to ~half, centered, so it reads as a search bar rather than a banner. */
|
||||
:global(.topbar-search) {
|
||||
width: 55%;
|
||||
min-width: 26rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
@@ -417,13 +483,19 @@
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
padding-right: 0.6rem;
|
||||
padding-right: 1.1rem;
|
||||
}
|
||||
|
||||
.topbar-brand img {
|
||||
height: 1.6rem;
|
||||
}
|
||||
|
||||
/* On phones let the user button drop onto its own line below the icon
|
||||
toggles again (the desktop nowrap rule would otherwise overflow). */
|
||||
.topbar-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-trigger {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
let {
|
||||
category,
|
||||
title,
|
||||
icon
|
||||
}: {
|
||||
category: string;
|
||||
title: string;
|
||||
icon: ComponentType;
|
||||
} = $props();
|
||||
|
||||
const Icon = $derived(icon);
|
||||
</script>
|
||||
|
||||
<section class="page-header" aria-label={`${title} page header`}>
|
||||
<div class="page-header-icon" aria-hidden="true">
|
||||
<Icon size={20} strokeWidth={1.9} />
|
||||
</div>
|
||||
<div class="page-header-copy">
|
||||
<p>{category}</p>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.95rem;
|
||||
margin-bottom: 1.15rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.15rem;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.page-header-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 0.9rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-brand-tint) 72%, var(--color-bg-surface));
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.page-header-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header-copy p {
|
||||
margin: 0 0 0.2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-header-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-header {
|
||||
padding: 0.92rem 0.95rem;
|
||||
}
|
||||
|
||||
.page-header-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.82rem;
|
||||
}
|
||||
|
||||
.page-header-copy h1 {
|
||||
font-size: 1.28rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
import { pageMeta, type SearchItem } from '$lib/navigation/client-navigation';
|
||||
|
||||
let {
|
||||
query = $bindable(''),
|
||||
open = $bindable(false),
|
||||
focusRequest = 0,
|
||||
filteredSearchItems,
|
||||
hiddenResultCount,
|
||||
label = 'Search the workspace',
|
||||
placeholder = 'Search products, mixes, sessions, and pages...',
|
||||
className = '',
|
||||
showShortcut = true,
|
||||
onRunSearchItem
|
||||
}: {
|
||||
query?: string;
|
||||
open?: boolean;
|
||||
focusRequest?: number;
|
||||
filteredSearchItems: SearchItem[];
|
||||
hiddenResultCount: number;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
showShortcut?: boolean;
|
||||
onRunSearchItem: (item: SearchItem) => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let root: HTMLDivElement | null = null;
|
||||
let input: HTMLInputElement | null = null;
|
||||
let highlightedIndex = $state(-1);
|
||||
const resultsId = `workspace-search-results-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const activeDescendant = $derived(
|
||||
highlightedIndex >= 0 ? `${resultsId}-${highlightedIndex}` : undefined
|
||||
);
|
||||
|
||||
function openSearch() {
|
||||
open = true;
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
open = false;
|
||||
highlightedIndex = -1;
|
||||
}
|
||||
|
||||
async function selectItem(item: SearchItem) {
|
||||
closeSearch();
|
||||
await onRunSearchItem(item);
|
||||
}
|
||||
|
||||
function moveHighlight(direction: 1 | -1) {
|
||||
if (!filteredSearchItems.length) {
|
||||
highlightedIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
open = true;
|
||||
|
||||
if (highlightedIndex === -1) {
|
||||
highlightedIndex = direction === 1 ? 0 : filteredSearchItems.length - 1;
|
||||
return;
|
||||
}
|
||||
|
||||
highlightedIndex = (highlightedIndex + direction + filteredSearchItems.length) % filteredSearchItems.length;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveHighlight(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveHighlight(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && open) {
|
||||
const candidate =
|
||||
highlightedIndex >= 0 ? filteredSearchItems[highlightedIndex] : filteredSearchItems[0];
|
||||
|
||||
if (candidate) {
|
||||
event.preventDefault();
|
||||
void selectItem(candidate);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeSearch();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
focusRequest;
|
||||
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
tick().then(() => input?.focus());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
highlightedIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filteredSearchItems.length) {
|
||||
highlightedIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (highlightedIndex >= filteredSearchItems.length) {
|
||||
highlightedIndex = filteredSearchItems.length - 1;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (root?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeSearch();
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', handlePointerDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handlePointerDown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={root} class={`workspace-search ${className}`.trim()}>
|
||||
<label class="search-box">
|
||||
<span class="search-icon" aria-hidden="true"></span>
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={query}
|
||||
type="search"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
aria-controls={resultsId}
|
||||
aria-activedescendant={activeDescendant}
|
||||
aria-autocomplete="list"
|
||||
placeholder={placeholder}
|
||||
onfocus={openSearch}
|
||||
oninput={openSearch}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
{#if showShortcut}
|
||||
<kbd>/</kbd>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if open}
|
||||
<div class="search-results" id={resultsId} role="listbox">
|
||||
{#if filteredSearchItems.length}
|
||||
{#each filteredSearchItems as item, index (item.href + item.label)}
|
||||
{@const ResultIcon = pageMeta(item.href).icon}
|
||||
<button
|
||||
id={`${resultsId}-${index}`}
|
||||
class:active={index === highlightedIndex}
|
||||
class="search-result"
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlightedIndex}
|
||||
onmouseenter={() => (highlightedIndex = index)}
|
||||
onclick={() => void selectItem(item)}
|
||||
>
|
||||
<span class="search-result-icon" aria-hidden="true">
|
||||
<ResultIcon size={18} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div class="search-result-copy">
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.description}</span>
|
||||
</div>
|
||||
<small>{item.href}</small>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if hiddenResultCount > 0}
|
||||
<p class="search-more">
|
||||
{hiddenResultCount} more {hiddenResultCount === 1 ? 'match' : 'matches'}, keep typing to narrow.
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="search-empty">
|
||||
<strong>No results</strong>
|
||||
<span>Try searching for mixes, sessions, or pages.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workspace-search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.64rem;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.72rem 0.82rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.82rem;
|
||||
background: color-mix(in srgb, var(--panel-soft) 60%, var(--color-bg-surface));
|
||||
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.workspace-search:focus-within .search-box,
|
||||
.search-box:hover {
|
||||
border-color: color-mix(in srgb, var(--color-brand) 24%, var(--line));
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.workspace-search:focus-within .search-box {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 0.82rem;
|
||||
height: 0.82rem;
|
||||
border: 2px solid var(--color-text-muted);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.search-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -0.28rem;
|
||||
bottom: -0.18rem;
|
||||
width: 0.42rem;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-text-muted);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 35;
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
max-height: min(26rem, calc(100vh - 10rem));
|
||||
overflow: auto;
|
||||
padding: 0.46rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 18px 44px rgba(11, 18, 14, 0.14);
|
||||
}
|
||||
|
||||
.search-result,
|
||||
.search-empty {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: none;
|
||||
border-radius: 0.82rem;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.search-result:hover,
|
||||
.search-result.active {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.search-result-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.7rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-brand-tint) 72%, var(--color-bg-surface));
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
/* Strip the inline-SVG baseline gap so the glyph is truly centred, not nudged
|
||||
down-and-left inside the badge. */
|
||||
.search-result-icon :global(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-result-copy {
|
||||
min-width: 0;
|
||||
/* Take the middle slot so the trailing href stays right-aligned. */
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-result strong,
|
||||
.search-empty strong {
|
||||
display: block;
|
||||
font-size: 0.94rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search-result span,
|
||||
.search-empty span,
|
||||
.search-result small,
|
||||
.search-more {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-result span {
|
||||
display: block;
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.search-result small {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.search-more {
|
||||
margin: 0.12rem 0.28rem 0;
|
||||
padding: 0.48rem 0.52rem 0.1rem;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1rem 0.42rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.42rem;
|
||||
color: var(--muted);
|
||||
background: var(--color-bg-surface);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.search-box {
|
||||
min-height: 2.55rem;
|
||||
padding: 0.66rem 0.74rem;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
max-height: min(22rem, calc(100vh - 8rem));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,85 +0,0 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
label = 'Search the workspace',
|
||||
placeholder = 'Search products, mixes, sessions, and pages...',
|
||||
className = '',
|
||||
onClick
|
||||
}: {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button class={`search-box ${className}`.trim()} type="button" aria-label={label} onclick={onClick}>
|
||||
<span class="search-icon"></span>
|
||||
<span class="search-placeholder">{placeholder}</span>
|
||||
<kbd>/</kbd>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.search-box {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.64rem;
|
||||
width: 100%;
|
||||
padding: 0.72rem 0.82rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.82rem;
|
||||
background: var(--panel-soft);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.search-box:hover {
|
||||
border-color: color-mix(in srgb, var(--color-brand) 24%, var(--line));
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.search-box:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 0.82rem;
|
||||
height: 0.82rem;
|
||||
border: 2px solid var(--color-text-muted);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.search-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -0.28rem;
|
||||
bottom: -0.18rem;
|
||||
width: 0.42rem;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-text-muted);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1rem 0.42rem;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 0.42rem;
|
||||
color: var(--muted);
|
||||
background: var(--color-bg-surface);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<script lang="ts">
|
||||
import { Search } from 'lucide-svelte';
|
||||
|
||||
import { label } from '$lib/ordering/format';
|
||||
import type { CustomerVisibilityRow } from '$lib/types';
|
||||
|
||||
let {
|
||||
rows,
|
||||
onToggle,
|
||||
onBulk
|
||||
}: {
|
||||
rows: CustomerVisibilityRow[];
|
||||
onToggle: (row: CustomerVisibilityRow) => void | Promise<void>;
|
||||
onBulk: (productIds: number[], visible: boolean) => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let query = $state('');
|
||||
|
||||
const visibleCount = $derived(rows.filter((r) => r.visible).length);
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(
|
||||
(r) => r.name.toLowerCase().includes(q) || r.sku.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
// Group the filtered rows by category, keeping first-seen order so the list is
|
||||
// stable as the search narrows. Each group carries its own visible tally so the
|
||||
// header can show progress without a second pass at render time.
|
||||
const groups = $derived.by(() => {
|
||||
const map = new Map<string, CustomerVisibilityRow[]>();
|
||||
for (const row of filtered) {
|
||||
const list = map.get(row.category) ?? [];
|
||||
list.push(row);
|
||||
map.set(row.category, list);
|
||||
}
|
||||
return [...map.entries()].map(([category, items]) => ({
|
||||
category,
|
||||
items,
|
||||
visible: items.filter((i) => i.visible).length
|
||||
}));
|
||||
});
|
||||
|
||||
/** Product ids in `set` whose current visibility differs from `target`. */
|
||||
function idsToChange(set: CustomerVisibilityRow[], target: boolean): number[] {
|
||||
return set.filter((r) => r.visible !== target).map((r) => r.product_id);
|
||||
}
|
||||
|
||||
function setGroup(items: CustomerVisibilityRow[], visible: boolean) {
|
||||
void onBulk(idsToChange(items, visible), visible);
|
||||
}
|
||||
|
||||
function setFiltered(visible: boolean) {
|
||||
void onBulk(idsToChange(filtered, visible), visible);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="visibility-manager">
|
||||
<div class="vis-toolbar">
|
||||
<label class="vis-search">
|
||||
<Search size={15} strokeWidth={2} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder="Search products or SKU"
|
||||
aria-label="Search products"
|
||||
/>
|
||||
</label>
|
||||
<div class="vis-summary">
|
||||
<span class="count-strong">{visibleCount}</span>
|
||||
<span class="count-of">of {rows.length} visible</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if rows.length}
|
||||
<div class="vis-bulk">
|
||||
<span>{filtered.length === rows.length ? 'All products' : `${filtered.length} shown`}</span>
|
||||
<div class="vis-bulk-actions">
|
||||
<button class="link" type="button" onclick={() => setFiltered(true)}>Show all</button>
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<button class="link" type="button" onclick={() => setFiltered(false)}>Hide all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filtered.length}
|
||||
<div class="vis-groups">
|
||||
{#each groups as group (group.category)}
|
||||
<section class="vis-group">
|
||||
<header class="vis-group-head">
|
||||
<h4>
|
||||
{label(group.category)}
|
||||
<span class="vis-group-count">{group.visible}/{group.items.length}</span>
|
||||
</h4>
|
||||
<div class="vis-group-actions">
|
||||
<button class="link" type="button" onclick={() => setGroup(group.items, true)}>All</button>
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<button class="link" type="button" onclick={() => setGroup(group.items, false)}>None</button>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="vis-list">
|
||||
{#each group.items as row (row.product_id)}
|
||||
<li>
|
||||
<label class="vis-row" class:on={row.visible}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.visible}
|
||||
onchange={() => onToggle(row)}
|
||||
/>
|
||||
<span class="vis-name">{row.name}</span>
|
||||
<span class="vis-sku">{row.sku}</span>
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="vis-empty">No products match “{query}”.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="vis-empty">No products in the catalogue yet. Add products to control what this customer can order.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.visibility-manager {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Toolbar: search + running visible tally ─────────────────── */
|
||||
.vis-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vis-search {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-text-muted);
|
||||
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.vis-search:focus-within {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
|
||||
}
|
||||
|
||||
.vis-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.vis-summary {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.32rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.count-strong {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-brand);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.count-of {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Bulk row ────────────────────────────────────────────────── */
|
||||
.vis-bulk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.vis-bulk-actions,
|
||||
.vis-group-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Category groups ─────────────────────────────────────────── */
|
||||
.vis-groups {
|
||||
display: grid;
|
||||
gap: 1.05rem;
|
||||
}
|
||||
|
||||
.vis-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.vis-group-head h4 {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.vis-group-count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.vis-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.vis-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-bg-surface);
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.vis-row:hover {
|
||||
border-color: color-mix(in srgb, var(--color-brand) 35%, var(--color-border));
|
||||
}
|
||||
|
||||
.vis-row.on {
|
||||
background: color-mix(in srgb, var(--color-brand-tint) 55%, var(--color-bg-surface));
|
||||
border-color: color-mix(in srgb, var(--color-brand) 28%, var(--color-border));
|
||||
}
|
||||
|
||||
.vis-row input {
|
||||
accent-color: var(--color-brand);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vis-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.vis-sku {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vis-empty {
|
||||
margin: 0.3rem 0;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.84rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,546 @@
|
||||
<script lang="ts">
|
||||
import { ClipboardList, Clock, Eye, FlaskConical, Info, UserPlus, X } from 'lucide-svelte';
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import { label, money, statusTone } from '$lib/ordering/format';
|
||||
import CustomerProductVisibility from '$lib/components/ordering/CustomerProductVisibility.svelte';
|
||||
import type {
|
||||
CustomerPricing,
|
||||
CustomerVisibilityRow,
|
||||
EditorMixRow,
|
||||
OrderingCustomer,
|
||||
OrderingCustomerUser,
|
||||
Order
|
||||
} from '$lib/types';
|
||||
|
||||
let {
|
||||
customer,
|
||||
onChanged,
|
||||
onClose
|
||||
}: {
|
||||
customer: OrderingCustomer;
|
||||
onChanged: (updated?: OrderingCustomer) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
type TabId = 'details' | 'access' | 'orders' | 'mixes' | 'history';
|
||||
const TABS: { id: TabId; label: string; icon: typeof Info }[] = [
|
||||
{ id: 'details', label: 'Details', icon: Info },
|
||||
{ id: 'access', label: 'Access', icon: Eye },
|
||||
{ id: 'orders', label: 'Orders', icon: ClipboardList },
|
||||
{ id: 'mixes', label: 'Mixes', icon: FlaskConical },
|
||||
{ id: 'history', label: 'History', icon: Clock }
|
||||
];
|
||||
|
||||
let activeTab = $state<TabId>('details');
|
||||
let loaded = $state(new Set<TabId>());
|
||||
let loading = $state(false);
|
||||
|
||||
// Per-customer data. Cleared whenever the selected customer changes.
|
||||
let users = $state<OrderingCustomerUser[]>([]);
|
||||
let pricing = $state<CustomerPricing | null>(null);
|
||||
let visibility = $state<CustomerVisibilityRow[]>([]);
|
||||
let orders = $state<Order[]>([]);
|
||||
let mixes = $state<EditorMixRow[]>([]);
|
||||
|
||||
let notesDraft = $state('');
|
||||
let addingUser = $state(false);
|
||||
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
|
||||
|
||||
// Reset and reload only when the *id* changes. The parent re-passes a fresh
|
||||
// customer object after status/notes saves (same id); those must not reset the
|
||||
// open tab or reload everything.
|
||||
let lastId = -1;
|
||||
$effect(() => {
|
||||
const id = customer.id;
|
||||
if (id === lastId) return;
|
||||
lastId = id;
|
||||
activeTab = 'details';
|
||||
loaded = new Set();
|
||||
users = [];
|
||||
pricing = null;
|
||||
visibility = [];
|
||||
orders = [];
|
||||
mixes = [];
|
||||
notesDraft = customer.notes ?? '';
|
||||
addingUser = false;
|
||||
void loadTab('details');
|
||||
});
|
||||
|
||||
async function loadTab(tab: TabId) {
|
||||
const id = customer.id;
|
||||
loading = true;
|
||||
try {
|
||||
if (tab === 'details') {
|
||||
const [u, p] = await Promise.all([
|
||||
api.orderingAdmin.customerUsers(id),
|
||||
api.orderingAdmin.pricing(id).catch(() => null)
|
||||
]);
|
||||
if (id !== customer.id) return;
|
||||
users = u;
|
||||
pricing = p;
|
||||
} else if (tab === 'access') {
|
||||
const v = await api.orderingAdmin.visibility(id);
|
||||
if (id !== customer.id) return;
|
||||
visibility = v;
|
||||
} else if (tab === 'orders' || tab === 'history') {
|
||||
const o = await api.orderingAdmin.orders({ customer_id: id });
|
||||
if (id !== customer.id) return;
|
||||
orders = o;
|
||||
loaded = new Set(loaded).add('orders');
|
||||
} else if (tab === 'mixes') {
|
||||
const m = await api.editorMixes({ client_name: customer.name });
|
||||
if (id !== customer.id) return;
|
||||
mixes = m;
|
||||
}
|
||||
loaded = new Set(loaded).add(tab);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load customer data.');
|
||||
} finally {
|
||||
if (id === customer.id) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTab(tab: TabId) {
|
||||
activeTab = tab;
|
||||
if (!loaded.has(tab)) await loadTab(tab);
|
||||
}
|
||||
|
||||
function onTabKeydown(event: KeyboardEvent, index: number) {
|
||||
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
|
||||
event.preventDefault();
|
||||
const next = (index + (event.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length;
|
||||
void selectTab(TABS[next].id);
|
||||
}
|
||||
|
||||
// ── Mutations ───────────────────────────────────────────────────────────────
|
||||
async function toggleStatus() {
|
||||
try {
|
||||
const updated = await api.orderingAdmin.updateCustomer(customer.id, {
|
||||
status: customer.status === 'active' ? 'disabled' : 'active'
|
||||
});
|
||||
toast.success(`Customer ${updated.status}.`);
|
||||
onChanged(updated);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNotes() {
|
||||
try {
|
||||
const updated = await api.orderingAdmin.updateCustomer(customer.id, { notes: notesDraft });
|
||||
toast.success('Notes saved.');
|
||||
onChanged(updated);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save notes.');
|
||||
}
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomerUser(customer.id, newUser);
|
||||
toast.success('User invited.');
|
||||
newUser = { full_name: '', email: '', role: 'buyer' };
|
||||
addingUser = false;
|
||||
users = await api.orderingAdmin.customerUsers(customer.id);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not add user.');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUserStatus(u: OrderingCustomerUser) {
|
||||
try {
|
||||
const next = u.status === 'suspended' ? 'active' : 'suspended';
|
||||
await api.orderingAdmin.updateCustomerUser(customer.id, u.id, { status: next });
|
||||
users = await api.orderingAdmin.customerUsers(customer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleVisibility(row: CustomerVisibilityRow) {
|
||||
try {
|
||||
await api.orderingAdmin.setVisibility(customer.id, { product_id: row.product_id, visible: !row.visible });
|
||||
visibility = await api.orderingAdmin.visibility(customer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkVisibility(productIds: number[], visible: boolean) {
|
||||
if (!productIds.length) return;
|
||||
const id = customer.id;
|
||||
try {
|
||||
await Promise.all(productIds.map((pid) => api.orderingAdmin.setVisibility(id, { product_id: pid, visible })));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not update visibility.');
|
||||
} finally {
|
||||
visibility = await api.orderingAdmin.visibility(id);
|
||||
}
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return (
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('') || '?'
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—';
|
||||
}
|
||||
|
||||
const customPriceCount = $derived(pricing?.product_prices.filter((p) => p.active).length ?? 0);
|
||||
|
||||
// Activity timeline merged from order milestones and user invites.
|
||||
const historyEvents = $derived.by(() => {
|
||||
const events: { when: string; text: string; tone: string }[] = [];
|
||||
for (const o of orders) {
|
||||
const ref = o.order_number ?? `#${o.id}`;
|
||||
if (o.submitted_at) events.push({ when: o.submitted_at, text: `Order ${ref} submitted`, tone: 'info' });
|
||||
if (o.updated_at) events.push({ when: o.updated_at, text: `Order ${ref} is ${label(o.status)}`, tone: statusTone(o.status) });
|
||||
}
|
||||
for (const u of users) {
|
||||
if (u.created_at) events.push({ when: u.created_at, text: `Invited ${u.full_name}`, tone: '' });
|
||||
}
|
||||
return events
|
||||
.filter((e) => e.when)
|
||||
.sort((a, b) => new Date(b.when).getTime() - new Date(a.when).getTime());
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="surface-card detail workspace">
|
||||
<div class="workspace-head">
|
||||
<div class="detail-head head-row">
|
||||
<div class="detail-title">
|
||||
<p class="eyebrow">{customer.client_code}</p>
|
||||
<h2>{customer.name}</h2>
|
||||
</div>
|
||||
<div class="detail-head-actions">
|
||||
<span class="pill {statusTone(customer.status)}">{customer.status}</span>
|
||||
<button
|
||||
class="secondary"
|
||||
onclick={toggleStatus}
|
||||
use:tooltip={customer.status === 'active'
|
||||
? 'Disable ordering for this customer'
|
||||
: 'Re-enable ordering for this customer'}
|
||||
>
|
||||
{customer.status === 'active' ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button class="icon-btn" onclick={onClose} aria-label="Close customer" use:tooltip={{ label: 'Close (Esc)', placement: 'bottom' }}>
|
||||
<X size={17} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace-tabs" role="tablist" aria-label="Customer sections">
|
||||
{#each TABS as tab, i (tab.id)}
|
||||
{@const Icon = tab.icon}
|
||||
<button
|
||||
class="workspace-tab"
|
||||
role="tab"
|
||||
id={`ws-tab-${tab.id}`}
|
||||
aria-selected={activeTab === tab.id}
|
||||
aria-controls={`ws-panel-${tab.id}`}
|
||||
tabindex={activeTab === tab.id ? 0 : -1}
|
||||
onclick={() => selectTab(tab.id)}
|
||||
onkeydown={(e) => onTabKeydown(e, i)}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} aria-hidden="true" />
|
||||
{tab.label}
|
||||
{#if tab.id === 'orders' && loaded.has('orders') && orders.length}<span class="tab-count">{orders.length}</span>{/if}
|
||||
{#if tab.id === 'mixes' && loaded.has('mixes') && mixes.length}<span class="tab-count">{mixes.length}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace-body" role="tabpanel" id={`ws-panel-${activeTab}`} aria-labelledby={`ws-tab-${activeTab}`}>
|
||||
{#if loading && !loaded.has(activeTab)}
|
||||
<div class="skeleton" aria-hidden="true">
|
||||
<span class="skel skel-line"></span>
|
||||
<span class="skel skel-line short"></span>
|
||||
<span class="skel skel-block"></span>
|
||||
</div>
|
||||
|
||||
<!-- ── Details ─────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'details'}
|
||||
<div class="ws-section">
|
||||
<dl class="facts">
|
||||
<div><dt>Client code</dt><dd>{customer.client_code}</dd></div>
|
||||
<div><dt>Discount</dt><dd>{customer.discount_percent ? `${customer.discount_percent}%` : 'None'}</dd></div>
|
||||
<div><dt>Price list</dt><dd>{pricing?.price_list_id ? `#${pricing.price_list_id}` : 'Default'}</dd></div>
|
||||
<div><dt>Custom prices</dt><dd>{customPriceCount || 'None'}</dd></div>
|
||||
<div><dt>Xero</dt><dd>{customer.xero_contact_id ? 'Linked' : 'Not linked'}</dd></div>
|
||||
<div><dt>People</dt><dd>{customer.user_count}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="ws-section">
|
||||
<div class="section-head"><h3>Notes</h3></div>
|
||||
<textarea class="notes" rows="3" placeholder="Internal notes about this customer" bind:value={notesDraft}></textarea>
|
||||
<div class="actions notes-actions">
|
||||
<button class="secondary" onclick={saveNotes} disabled={(customer.notes ?? '') === notesDraft}>Save notes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-section">
|
||||
<div class="section-head">
|
||||
<h3>People <span class="count">{users.length}</span></h3>
|
||||
<button class="link" onclick={() => (addingUser = !addingUser)}>
|
||||
<UserPlus size={14} strokeWidth={2} aria-hidden="true" />
|
||||
{addingUser ? 'Cancel' : 'Add person'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if users.length}
|
||||
<ul class="roster">
|
||||
{#each users as u (u.id)}
|
||||
<li>
|
||||
<span class="avatar" aria-hidden="true">{initials(u.full_name)}</span>
|
||||
<div class="who">
|
||||
<strong>{u.full_name}</strong>
|
||||
<span class="who-sub">{u.email}</span>
|
||||
</div>
|
||||
<span class="pill role-pill">{label(u.role)}</span>
|
||||
<span class="pill {statusTone(u.status)}">{u.status}</span>
|
||||
<button class="link" onclick={() => toggleUserStatus(u)}>
|
||||
{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">No people yet. Invite a buyer to give them portal access.</p>
|
||||
{/if}
|
||||
|
||||
{#if addingUser}
|
||||
<div class="create-panel add-user">
|
||||
<div class="form-row">
|
||||
<label>Full name<input placeholder="Jordan Lee" bind:value={newUser.full_name} /></label>
|
||||
<label>Email<input type="email" placeholder="jordan@acme.com" bind:value={newUser.email} /></label>
|
||||
<label>Role
|
||||
<select bind:value={newUser.role}>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="buyer">Buyer</option>
|
||||
<option value="accounts">Accounts</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions"><button class="primary" onclick={addUser}>Send invite</button></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Access ──────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'access'}
|
||||
<p class="muted">Choose which products this customer can see and order in the portal.</p>
|
||||
<CustomerProductVisibility rows={visibility} onToggle={toggleVisibility} onBulk={bulkVisibility} />
|
||||
|
||||
<!-- ── Orders ──────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'orders'}
|
||||
{#if orders.length}
|
||||
<table>
|
||||
<thead><tr><th>Order</th><th>Placed</th><th>Status</th><th class="amt">Subtotal</th></tr></thead>
|
||||
<tbody>
|
||||
{#each orders as o (o.id)}
|
||||
<tr>
|
||||
<td class="id-name">{o.order_number ?? `#${o.id}`}</td>
|
||||
<td>{fmtDate(o.submitted_at ?? o.created_at)}</td>
|
||||
<td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
|
||||
<td class="amt">{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="empty">No orders from this customer yet.</p>
|
||||
{/if}
|
||||
|
||||
<!-- ── Mixes ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'mixes'}
|
||||
<div class="section-head">
|
||||
<p class="muted" style="margin:0">Recipes linked to {customer.name}.</p>
|
||||
<a class="link" href="/editor">Open Mix Editor</a>
|
||||
</div>
|
||||
{#if mixes.length}
|
||||
<table>
|
||||
<thead><tr><th>Mix</th><th class="amt">Products</th><th>Visible</th></tr></thead>
|
||||
<tbody>
|
||||
{#each mixes as m (m.id)}
|
||||
<tr>
|
||||
<td class="id-name">{m.name}</td>
|
||||
<td class="amt">{m.product_count}</td>
|
||||
<td><span class="pill {m.visible ? 'pos' : 'muted-pill'}">{m.visible ? 'Visible' : 'Hidden'}</span></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="empty">No mixes matched “{customer.name}”. Mixes link to customers by client name.</p>
|
||||
{/if}
|
||||
|
||||
<!-- ── History ─────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'history'}
|
||||
{#if historyEvents.length}
|
||||
<ol class="timeline">
|
||||
{#each historyEvents as e (e.when + e.text)}
|
||||
<li>
|
||||
<span class="dot {e.tone}" aria-hidden="true"></span>
|
||||
<div class="event">
|
||||
<span class="event-text">{e.text}</span>
|
||||
<time>{new Date(e.when).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })}</time>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="empty">No recorded activity yet.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.head-row {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Details: facts grid ──────────────────────────────────────── */
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
|
||||
gap: 0.9rem 1.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
.facts dt {
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.facts dd {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.notes {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
.notes:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
|
||||
}
|
||||
.notes-actions { justify-content: flex-end; margin-top: 0.6rem; }
|
||||
|
||||
.link { display: inline-flex; align-items: center; gap: 0.32rem; }
|
||||
.link :global(svg) { display: block; }
|
||||
|
||||
/* ── People roster ────────────────────────────────────────────── */
|
||||
.roster { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.45rem; }
|
||||
.roster li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.who { flex: 1; min-width: 0; display: grid; gap: 0.1rem; }
|
||||
.who strong { font-size: 0.86rem; font-weight: 600; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.who-sub { font-size: 0.76rem; color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.role-pill { background: color-mix(in srgb, var(--panel-soft) 70%, var(--color-bg-surface)); color: var(--color-text-secondary); }
|
||||
.add-user { margin-top: 0.7rem; }
|
||||
.add-user .form-row { margin-bottom: 0.7rem; }
|
||||
.add-user .form-row label { flex: 1; min-width: 9rem; }
|
||||
.add-user .actions { justify-content: flex-end; }
|
||||
|
||||
.amt { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
/* ── History timeline ─────────────────────────────────────────── */
|
||||
.timeline { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
|
||||
.timeline li { display: flex; gap: 0.75rem; padding: 0.1rem 0; }
|
||||
.timeline .dot {
|
||||
flex-shrink: 0;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
margin-top: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-muted);
|
||||
position: relative;
|
||||
}
|
||||
/* Connecting line between events. */
|
||||
.timeline li:not(:last-child) .dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0.85rem;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: calc(100% + 0.2rem);
|
||||
background: var(--color-divider);
|
||||
}
|
||||
.timeline .dot.pos { background: var(--color-success); }
|
||||
.timeline .dot.info { background: var(--color-info); }
|
||||
.timeline .dot.warn { background: var(--color-warning); }
|
||||
.timeline .dot.danger { background: var(--color-error); }
|
||||
.timeline .event { display: flex; flex-direction: column; gap: 0.05rem; padding-bottom: 0.85rem; }
|
||||
.event-text { font-size: 0.85rem; color: var(--color-text-primary); }
|
||||
.event time { font-size: 0.73rem; color: var(--color-text-muted); }
|
||||
|
||||
/* ── Loading skeleton ─────────────────────────────────────────── */
|
||||
.skeleton { display: grid; gap: 0.7rem; }
|
||||
.skel {
|
||||
border-radius: var(--radius-control);
|
||||
background: linear-gradient(90deg, var(--panel-soft) 25%, var(--color-surface-hover) 37%, var(--panel-soft) 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: skel-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skel-line { height: 1rem; width: 60%; }
|
||||
.skel-line.short { width: 35%; }
|
||||
.skel-block { height: 9rem; width: 100%; }
|
||||
@keyframes skel-shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: 0 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skel { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,792 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import type {
|
||||
InternalRole,
|
||||
InternalRoleCreateInput,
|
||||
InternalRoleModuleDefinition,
|
||||
InternalRoleUpdateInput
|
||||
} from '$lib/types';
|
||||
import { Pencil, ShieldCheck, Trash2, TriangleAlert, Waypoints, Plus } from 'lucide-svelte';
|
||||
|
||||
let roles = $state<InternalRole[]>([]);
|
||||
let modules = $state<InternalRoleModuleDefinition[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const [roleList, moduleList] = await Promise.all([
|
||||
api.accessRoles(),
|
||||
api.accessRoleModules()
|
||||
]);
|
||||
roles = roleList;
|
||||
modules = moduleList;
|
||||
} catch (err: unknown) {
|
||||
loadError = err instanceof Error ? err.message : 'Failed to load roles';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function emptyModulePermissions() {
|
||||
return Object.fromEntries(modules.map((module) => [module.key, 'none'])) as Record<string, string>;
|
||||
}
|
||||
|
||||
type FormMode = 'create' | 'edit';
|
||||
let formOpen = $state(false);
|
||||
let formMode = $state<FormMode>('create');
|
||||
let formRoleId = $state<number | null>(null);
|
||||
let formName = $state('');
|
||||
let formDescription = $state('');
|
||||
let formModulePermissions = $state<Record<string, string>>({});
|
||||
let formProtected = $state(false);
|
||||
let formSaving = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
function openCreate() {
|
||||
formMode = 'create';
|
||||
formRoleId = null;
|
||||
formName = '';
|
||||
formDescription = '';
|
||||
formModulePermissions = emptyModulePermissions();
|
||||
formProtected = false;
|
||||
formError = '';
|
||||
formOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(role: InternalRole) {
|
||||
formMode = 'edit';
|
||||
formRoleId = role.id;
|
||||
formName = role.name;
|
||||
formDescription = role.description ?? '';
|
||||
formModulePermissions = { ...emptyModulePermissions(), ...role.module_permissions };
|
||||
formProtected = role.is_protected;
|
||||
formError = '';
|
||||
formOpen = true;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
if (formSaving) return;
|
||||
formOpen = false;
|
||||
}
|
||||
|
||||
function setModuleLevel(moduleKey: string, level: string) {
|
||||
formModulePermissions = { ...formModulePermissions, [moduleKey]: level };
|
||||
}
|
||||
|
||||
function summary(role: InternalRole) {
|
||||
return modules
|
||||
.map((module) => {
|
||||
const level = role.module_permissions[module.key];
|
||||
return level && level !== 'none' ? `${module.label}: ${level}` : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
formError = '';
|
||||
const name = formName.trim();
|
||||
if (!name) {
|
||||
formError = 'Role name is required';
|
||||
return;
|
||||
}
|
||||
|
||||
formSaving = true;
|
||||
const tid = toast.loading(formMode === 'create' ? 'Creating role…' : 'Saving role…');
|
||||
try {
|
||||
const payload: InternalRoleCreateInput | InternalRoleUpdateInput = {
|
||||
name,
|
||||
description: formDescription.trim() || null,
|
||||
module_permissions: formModulePermissions
|
||||
};
|
||||
|
||||
if (formMode === 'create') {
|
||||
await api.createAccessRole(payload as InternalRoleCreateInput);
|
||||
} else if (formRoleId != null) {
|
||||
await api.updateAccessRole(formRoleId, payload);
|
||||
}
|
||||
|
||||
toast.dismiss(tid);
|
||||
toast.success(formMode === 'create' ? 'Role created' : 'Role updated');
|
||||
formOpen = false;
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(tid);
|
||||
const message = err instanceof Error ? err.message : 'An error occurred';
|
||||
formError = message;
|
||||
toast.error(message);
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
let deleteRole = $state<InternalRole | null>(null);
|
||||
let deleting = $state(false);
|
||||
|
||||
function openDelete(role: InternalRole) {
|
||||
deleteRole = role;
|
||||
}
|
||||
|
||||
function closeDelete() {
|
||||
if (deleting) return;
|
||||
deleteRole = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteRole) return;
|
||||
deleting = true;
|
||||
const tid = toast.loading('Deleting role…');
|
||||
try {
|
||||
await api.deleteAccessRole(deleteRole.id);
|
||||
toast.dismiss(tid);
|
||||
toast.success(`Deleted ${deleteRole.name}`);
|
||||
deleteRole = null;
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete role');
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel-section">
|
||||
<header class="panel-header">
|
||||
<div class="header-copy">
|
||||
<h2>Roles</h2>
|
||||
<p>Define which modules each role can open, edit, or manage.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" onclick={openCreate}>
|
||||
<Plus size={16} strokeWidth={2.2} /> Add role
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="state-msg">Loading roles…</p>
|
||||
{:else if loadError}
|
||||
<p class="state-msg error"><TriangleAlert size={15} strokeWidth={2.2} /> {loadError}</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table class="roles-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>Assigned users</th>
|
||||
<th>Module access</th>
|
||||
<th class="actions-col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each roles as role (role.id)}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="role-cell">
|
||||
<div class="role-title-row">
|
||||
<strong>{role.name}</strong>
|
||||
{#if role.is_protected}
|
||||
<span class="protected-chip">
|
||||
<ShieldCheck size={12} strokeWidth={2.4} /> Protected
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if role.description}
|
||||
<p>{role.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>{role.user_count}</td>
|
||||
<td class="summary-cell">{summary(role) || 'No module access'}</td>
|
||||
<td class="actions-col">
|
||||
<div class="row-actions">
|
||||
<button type="button" class="icon-btn" title="Edit role" onclick={() => openEdit(role)}>
|
||||
<Pencil size={15} strokeWidth={2.1} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
title={role.is_protected
|
||||
? 'Lean and admin roles cannot be deleted'
|
||||
: role.user_count > 0
|
||||
? 'Reassign users before deleting this role'
|
||||
: 'Delete role'}
|
||||
disabled={role.is_protected || role.user_count > 0}
|
||||
onclick={() => openDelete(role)}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={2.1} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if formOpen}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeForm}>
|
||||
<div
|
||||
class="modal-card modal-card-wide"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="role-form-title"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeForm(); }}
|
||||
>
|
||||
<div class="modal-top">
|
||||
<div class="modal-icon"><Waypoints size={20} strokeWidth={2.2} /></div>
|
||||
<div>
|
||||
<h2 id="role-form-title" class="modal-title">{formMode === 'create' ? 'Add role' : 'Edit role'}</h2>
|
||||
<p class="modal-text">Module access levels are translated into the underlying permissions automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="modal-form" onsubmit={(event) => { event.preventDefault(); saveForm(); }}>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="rf-name">Role name</label>
|
||||
<input id="rf-name" type="text" bind:value={formName} disabled={formProtected && formMode === 'edit'} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="rf-description">Description</label>
|
||||
<input id="rf-description" type="text" bind:value={formDescription} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="permissions-section">
|
||||
<div class="permissions-head">
|
||||
<div>
|
||||
<h3>Module access</h3>
|
||||
<p>Each row controls where this role can go and what it can do there.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="permissions-scroll">
|
||||
<table class="permissions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
<th>What it covers</th>
|
||||
<th>Access level</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each modules as module (module.key)}
|
||||
<tr>
|
||||
<td class="module-name-cell">
|
||||
<strong>{module.label}</strong>
|
||||
</td>
|
||||
<td class="module-description-cell">{module.description}</td>
|
||||
<td class="module-level-cell">
|
||||
<label class="matrix-select">
|
||||
<span class="sr-only">Access level for {module.label}</span>
|
||||
<select
|
||||
value={formModulePermissions[module.key] ?? 'none'}
|
||||
onchange={(event) => setModuleLevel(module.key, (event.currentTarget as HTMLSelectElement).value)}
|
||||
>
|
||||
{#each module.levels as level (level)}
|
||||
<option value={level}>{level}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formError}
|
||||
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {formError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="modal-cancel" onclick={closeForm} disabled={formSaving}>Cancel</button>
|
||||
<button type="submit" class="btn-primary" disabled={formSaving}>
|
||||
{formSaving ? 'Saving…' : formMode === 'create' ? 'Create role' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if deleteRole}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeDelete}>
|
||||
<div
|
||||
class="modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-role-title"
|
||||
tabindex="-1"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') closeDelete(); }}
|
||||
>
|
||||
<div class="modal-icon danger"><Trash2 size={20} strokeWidth={2.2} /></div>
|
||||
<h2 id="delete-role-title" class="modal-title">Delete role?</h2>
|
||||
<p class="modal-text">
|
||||
This removes <strong>{deleteRole.name}</strong>. Users must be reassigned first.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="modal-cancel" onclick={closeDelete} disabled={deleting}>Cancel</button>
|
||||
<button type="button" class="modal-confirm" onclick={confirmDelete} disabled={deleting}>
|
||||
{deleting ? 'Deleting…' : 'Delete role'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.panel-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 1.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.header-copy h2,
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-copy p,
|
||||
.modal-text {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.modal-confirm,
|
||||
.modal-cancel,
|
||||
.icon-btn {
|
||||
transition: opacity 140ms ease, border-color 140ms ease, color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 1.1rem;
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
.modal-confirm:disabled,
|
||||
.modal-cancel:disabled,
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
padding: 1.5rem 1.75rem;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state-msg.error,
|
||||
.form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 1.75rem 1.75rem;
|
||||
}
|
||||
|
||||
.roles-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.roles-table th,
|
||||
.roles-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.roles-table th {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.role-cell p,
|
||||
.summary-cell {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.role-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.protected-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.08rem 0.42rem;
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, transparent);
|
||||
color: var(--color-brand);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover:not(:disabled) {
|
||||
color: #c53030;
|
||||
border-color: color-mix(in srgb, #e53e3e 45%, transparent);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(34rem, 100%);
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.modal-card-wide {
|
||||
width: min(54rem, 100%);
|
||||
max-height: min(88vh, 60rem);
|
||||
}
|
||||
|
||||
.modal-top {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border-radius: 0.8rem;
|
||||
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.modal-icon.danger {
|
||||
background: #fdecee;
|
||||
color: #b3261e;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.field,
|
||||
.matrix-select {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.field label,
|
||||
.matrix-select span {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.matrix-select select {
|
||||
width: 100%;
|
||||
padding: 0.58rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.permissions-section {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.permissions-head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.permissions-head p {
|
||||
margin: 0.28rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.permissions-scroll {
|
||||
min-height: 0;
|
||||
max-height: min(46vh, 30rem);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.permissions-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.permissions-table th,
|
||||
.permissions-table td {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.permissions-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--panel-soft));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.permissions-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.module-name-cell {
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
.module-name-cell strong {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.module-description-cell {
|
||||
color: var(--muted);
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.module-level-cell {
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
.module-level-cell .matrix-select {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: color-mix(in srgb, #e53e3e 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
padding: 0.55rem 1.1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-confirm {
|
||||
padding: 0.55rem 1.1rem;
|
||||
background: #b3261e;
|
||||
border: 1px solid #b3261e;
|
||||
color: #fff;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.permissions-scroll {
|
||||
max-height: min(44vh, 26rem);
|
||||
}
|
||||
|
||||
.permissions-table,
|
||||
.permissions-table thead,
|
||||
.permissions-table tbody,
|
||||
.permissions-table tr,
|
||||
.permissions-table th,
|
||||
.permissions-table td {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.permissions-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.permissions-table tbody {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.permissions-table tr {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.permissions-table td {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.module-level-cell .matrix-select {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.module-level-cell .matrix-select .sr-only {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,851 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import { api } from '$lib/api';
|
||||
import { clientSession } from '$lib/session';
|
||||
import { toast } from '$lib/toast';
|
||||
import type { InternalRoleOption, InternalUser } from '$lib/types';
|
||||
import { UserPlus, Pencil, KeyRound, Trash2, ShieldCheck, TriangleAlert } from 'lucide-svelte';
|
||||
|
||||
let users = $state<InternalUser[]>([]);
|
||||
let roles = $state<InternalRoleOption[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
|
||||
const currentUserId = $derived($clientSession?.user_id ?? null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
const [userList, roleList] = await Promise.all([
|
||||
api.accessUsers(),
|
||||
api.accessAssignableRoles()
|
||||
]);
|
||||
users = userList;
|
||||
roles = roleList;
|
||||
} catch (err: unknown) {
|
||||
loadError = err instanceof Error ? err.message : 'Failed to load users';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
// ── Create / edit modal ───────────────────────────────────────
|
||||
type FormMode = 'create' | 'edit';
|
||||
let formOpen = $state(false);
|
||||
let formMode = $state<FormMode>('create');
|
||||
let formUserId = $state<number | null>(null);
|
||||
let formName = $state('');
|
||||
let formEmail = $state('');
|
||||
let formRoleId = $state<number | null>(null);
|
||||
let formActive = $state(true);
|
||||
let formPassword = $state('');
|
||||
let formSaving = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
function openCreate() {
|
||||
formMode = 'create';
|
||||
formUserId = null;
|
||||
formName = '';
|
||||
formEmail = '';
|
||||
formRoleId = roles[0]?.id ?? null;
|
||||
formActive = true;
|
||||
formPassword = '';
|
||||
formError = '';
|
||||
formOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(user: InternalUser) {
|
||||
formMode = 'edit';
|
||||
formUserId = user.id;
|
||||
formName = user.name;
|
||||
formEmail = user.email;
|
||||
formRoleId = user.role_id;
|
||||
formActive = user.is_active;
|
||||
formPassword = '';
|
||||
formError = '';
|
||||
formOpen = true;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
if (formSaving) return;
|
||||
formOpen = false;
|
||||
}
|
||||
|
||||
const editingSelf = $derived(formMode === 'edit' && formUserId === currentUserId);
|
||||
|
||||
async function saveForm() {
|
||||
formError = '';
|
||||
const name = formName.trim();
|
||||
const email = formEmail.trim().toLowerCase();
|
||||
if (!name) {
|
||||
formError = 'Name is required';
|
||||
return;
|
||||
}
|
||||
if (!email || !email.includes('@')) {
|
||||
formError = 'A valid email is required';
|
||||
return;
|
||||
}
|
||||
if (formMode === 'create' && formPassword && formPassword.length < 8) {
|
||||
formError = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
formSaving = true;
|
||||
const tid = toast.loading(formMode === 'create' ? 'Creating user…' : 'Saving user…');
|
||||
try {
|
||||
if (formMode === 'create') {
|
||||
await api.createAccessUser({
|
||||
name,
|
||||
email,
|
||||
role_id: formRoleId,
|
||||
is_active: formActive,
|
||||
password: formPassword ? formPassword : null
|
||||
});
|
||||
} else if (formUserId != null) {
|
||||
await api.updateAccessUser(formUserId, {
|
||||
name,
|
||||
email,
|
||||
role_id: formRoleId,
|
||||
is_active: formActive
|
||||
});
|
||||
}
|
||||
toast.dismiss(tid);
|
||||
toast.success(formMode === 'create' ? 'User created' : 'User updated');
|
||||
formOpen = false;
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(tid);
|
||||
const msg = err instanceof Error ? err.message : 'An error occurred';
|
||||
formError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Password reset modal ──────────────────────────────────────
|
||||
let pwOpen = $state(false);
|
||||
let pwUser = $state<InternalUser | null>(null);
|
||||
let pwNew = $state('');
|
||||
let pwConfirm = $state('');
|
||||
let pwSaving = $state(false);
|
||||
let pwError = $state('');
|
||||
|
||||
function openPassword(user: InternalUser) {
|
||||
pwUser = user;
|
||||
pwNew = '';
|
||||
pwConfirm = '';
|
||||
pwError = '';
|
||||
pwOpen = true;
|
||||
}
|
||||
|
||||
function closePassword() {
|
||||
if (pwSaving) return;
|
||||
pwOpen = false;
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
pwError = '';
|
||||
if (pwNew.length < 8) {
|
||||
pwError = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
if (pwNew !== pwConfirm) {
|
||||
pwError = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
if (!pwUser) return;
|
||||
pwSaving = true;
|
||||
const tid = toast.loading('Updating password…');
|
||||
try {
|
||||
await api.setAccessUserPassword(pwUser.id, pwNew);
|
||||
toast.dismiss(tid);
|
||||
toast.success(`Password updated for ${pwUser.name}`);
|
||||
pwOpen = false;
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(tid);
|
||||
const msg = err instanceof Error ? err.message : 'An error occurred';
|
||||
pwError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
pwSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete modal ──────────────────────────────────────────────
|
||||
let deleteUser = $state<InternalUser | null>(null);
|
||||
let deleting = $state(false);
|
||||
|
||||
function openDelete(user: InternalUser) {
|
||||
deleteUser = user;
|
||||
}
|
||||
|
||||
function closeDelete() {
|
||||
if (deleting) return;
|
||||
deleteUser = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteUser) return;
|
||||
deleting = true;
|
||||
const tid = toast.loading('Deleting user…');
|
||||
try {
|
||||
await api.deleteAccessUser(deleteUser.id);
|
||||
toast.dismiss(tid);
|
||||
toast.success(`Deleted ${deleteUser.name}`);
|
||||
deleteUser = null;
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete user');
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Quick active toggle ───────────────────────────────────────
|
||||
async function toggleActive(user: InternalUser) {
|
||||
if (user.id === currentUserId) {
|
||||
toast.error('You cannot deactivate your own account');
|
||||
return;
|
||||
}
|
||||
const next = !user.is_active;
|
||||
const tid = toast.loading(next ? 'Enabling access…' : 'Disabling access…');
|
||||
try {
|
||||
const updated = await api.updateAccessUser(user.id, { is_active: next });
|
||||
users = users.map((u) => (u.id === user.id ? updated : u));
|
||||
toast.dismiss(tid);
|
||||
toast.success(next ? `${user.name} can sign in` : `${user.name}'s access is off`);
|
||||
} catch (err: unknown) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update access');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel-section">
|
||||
<header class="panel-header">
|
||||
<div class="header-copy">
|
||||
<h2>Users</h2>
|
||||
<p>Manage who can sign in to the workspace, their role, and their access.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" onclick={openCreate}>
|
||||
<UserPlus size={16} strokeWidth={2.2} /> Add user
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="state-msg">Loading users…</p>
|
||||
{:else if loadError}
|
||||
<p class="state-msg error"><TriangleAlert size={15} strokeWidth={2.2} /> {loadError}</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Access</th>
|
||||
<th class="actions-col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr class:inactive={!user.is_active}>
|
||||
<td>
|
||||
<span class="user-name">{user.name}</span>
|
||||
{#if user.id === currentUserId}<span class="you-chip">You</span>{/if}
|
||||
{#if user.is_protected}
|
||||
<span class="lean-chip" use:tooltip={'Lean owner, this account cannot be deleted'}>
|
||||
<ShieldCheck size={12} strokeWidth={2.4} /> Lean
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="email-cell">{user.email}</td>
|
||||
<td>{user.role ?? '—'}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="status-toggle"
|
||||
class:on={user.is_active}
|
||||
disabled={user.id === currentUserId}
|
||||
aria-label={user.id === currentUserId ? 'You cannot change your own access' : 'Toggle access'}
|
||||
use:tooltip={user.id === currentUserId
|
||||
? 'You cannot change your own access'
|
||||
: user.is_active
|
||||
? 'Turn sign-in access off'
|
||||
: 'Turn sign-in access on'}
|
||||
onclick={() => toggleActive(user)}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
{user.is_active ? 'Active' : 'Off'}
|
||||
</button>
|
||||
</td>
|
||||
<td class="actions-col">
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="Edit user"
|
||||
use:tooltip={'Edit user details'}
|
||||
onclick={() => openEdit(user)}
|
||||
>
|
||||
<Pencil size={15} strokeWidth={2.1} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="Reset password"
|
||||
use:tooltip={'Reset password'}
|
||||
onclick={() => openPassword(user)}
|
||||
>
|
||||
<KeyRound size={15} strokeWidth={2.1} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
aria-label="Delete user"
|
||||
use:tooltip={user.is_protected
|
||||
? 'Lean accounts cannot be deleted'
|
||||
: user.id === currentUserId
|
||||
? 'You cannot delete your own account'
|
||||
: 'Delete user'}
|
||||
disabled={user.is_protected || user.id === currentUserId}
|
||||
onclick={() => openDelete(user)}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={2.1} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create / edit modal -->
|
||||
{#if formOpen}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeForm}>
|
||||
<div
|
||||
class="modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-form-title"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') closeForm(); }}
|
||||
>
|
||||
<h2 id="user-form-title" class="modal-title">{formMode === 'create' ? 'Add user' : 'Edit user'}</h2>
|
||||
|
||||
<form class="modal-form" onsubmit={(e) => { e.preventDefault(); saveForm(); }}>
|
||||
<div class="field">
|
||||
<label for="uf-name">Full name</label>
|
||||
<input id="uf-name" type="text" bind:value={formName} autocomplete="off" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="uf-email">Email address</label>
|
||||
<input id="uf-email" type="email" bind:value={formEmail} autocomplete="off" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="uf-role">Role</label>
|
||||
<select id="uf-role" bind:value={formRoleId}>
|
||||
<option value={null}>No role (no access)</option>
|
||||
{#each roles as role (role.id)}
|
||||
<option value={role.id}>{role.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if formMode === 'create'}
|
||||
<div class="field">
|
||||
<label for="uf-pass">Initial password <span class="optional">(optional)</span></label>
|
||||
<input id="uf-pass" type="password" bind:value={formPassword} autocomplete="new-password" placeholder="Leave blank to use the shared password" />
|
||||
</div>
|
||||
{/if}
|
||||
<label class="check-row" class:disabled={editingSelf}>
|
||||
<input type="checkbox" bind:checked={formActive} disabled={editingSelf} />
|
||||
<span>Access enabled {#if editingSelf}<em>(you cannot disable your own access)</em>{/if}</span>
|
||||
</label>
|
||||
|
||||
{#if formError}
|
||||
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {formError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="modal-cancel" onclick={closeForm} disabled={formSaving}>Cancel</button>
|
||||
<button type="submit" class="btn-primary" disabled={formSaving}>
|
||||
{formSaving ? 'Saving…' : formMode === 'create' ? 'Create user' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Password reset modal -->
|
||||
{#if pwOpen && pwUser}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closePassword}>
|
||||
<div
|
||||
class="modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pw-title"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') closePassword(); }}
|
||||
>
|
||||
<div class="modal-icon"><KeyRound size={20} strokeWidth={2.2} /></div>
|
||||
<h2 id="pw-title" class="modal-title">Reset password</h2>
|
||||
<p class="modal-text">Set a new password for <strong>{pwUser.name}</strong>. They can change it later in their own settings.</p>
|
||||
|
||||
<form class="modal-form" onsubmit={(e) => { e.preventDefault(); savePassword(); }}>
|
||||
<div class="field">
|
||||
<label for="pw-new">New password</label>
|
||||
<input id="pw-new" type="password" bind:value={pwNew} autocomplete="new-password" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pw-confirm">Confirm password</label>
|
||||
<input id="pw-confirm" type="password" bind:value={pwConfirm} autocomplete="new-password" required />
|
||||
</div>
|
||||
|
||||
{#if pwError}
|
||||
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {pwError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="modal-cancel" onclick={closePassword} disabled={pwSaving}>Cancel</button>
|
||||
<button type="submit" class="btn-primary" disabled={pwSaving}>
|
||||
{pwSaving ? 'Updating…' : 'Set password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
{#if deleteUser}
|
||||
<div class="modal-backdrop" role="presentation" onclick={closeDelete}>
|
||||
<div
|
||||
class="modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="del-title"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') closeDelete(); }}
|
||||
>
|
||||
<div class="modal-icon danger"><Trash2 size={20} strokeWidth={2.2} /></div>
|
||||
<h2 id="del-title" class="modal-title">Delete user?</h2>
|
||||
<p class="modal-text">
|
||||
This permanently removes <strong>{deleteUser.name}</strong> ({deleteUser.email}) and their
|
||||
access. This cannot be undone.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="modal-cancel" onclick={closeDelete} disabled={deleting}>Cancel</button>
|
||||
<button type="button" class="modal-confirm" onclick={confirmDelete} disabled={deleting}>
|
||||
{deleting ? 'Deleting…' : 'Delete user'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.panel-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 1.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.header-copy h2 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-copy p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.55rem 1.1rem;
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
padding: 1.5rem 1.75rem;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state-msg.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────── */
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 1.75rem 1.75rem;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
text-align: left;
|
||||
padding: 0.7rem 0.75rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.users-table tr.inactive td {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.email-cell {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.you-chip,
|
||||
.lean-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.08rem 0.42rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.you-chip {
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lean-chip {
|
||||
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, transparent);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover:not(:disabled) {
|
||||
color: #c53030;
|
||||
border-color: color-mix(in srgb, #e53e3e 45%, transparent);
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
padding: 0.32rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.status-toggle .dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.status-toggle.on {
|
||||
color: var(--color-brand);
|
||||
border-color: color-mix(in srgb, var(--color-brand) 35%, transparent);
|
||||
}
|
||||
|
||||
.status-toggle.on .dot {
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.status-toggle:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Modal ──────────────────────────────────────────────────── */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(30rem, 100%);
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.modal-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border-radius: 0.8rem;
|
||||
background: color-mix(in srgb, var(--color-brand) 12%, transparent);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.modal-icon.danger {
|
||||
background: #fdecee;
|
||||
color: #b3261e;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field .optional {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
padding: 0.58rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
|
||||
}
|
||||
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-row.disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.check-row em {
|
||||
color: var(--muted);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: color-mix(in srgb, #e53e3e 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
color: #c53030;
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.modal-cancel,
|
||||
.modal-confirm {
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.modal-cancel:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-confirm {
|
||||
background: #b3261e;
|
||||
border: 1px solid #b3261e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-confirm:hover:not(:disabled) {
|
||||
background: #95201a;
|
||||
}
|
||||
|
||||
.modal-confirm:disabled,
|
||||
.modal-cancel:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,438 @@
|
||||
<script lang="ts">
|
||||
import { Plus, TriangleAlert, X } from 'lucide-svelte';
|
||||
|
||||
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
|
||||
import type { ThroughputProduct, ThroughputQuantityType } from '$lib/types';
|
||||
|
||||
let {
|
||||
products,
|
||||
editingId,
|
||||
saving,
|
||||
nDate = $bindable(''),
|
||||
nProductId = $bindable(''),
|
||||
nQuantity = $bindable(''),
|
||||
nType = $bindable<ThroughputQuantityType>('bags'),
|
||||
nBagSize = $bindable(''),
|
||||
nStaff = $bindable(''),
|
||||
nNotes = $bindable(''),
|
||||
nForOrder = $bindable(false),
|
||||
nForStock = $bindable(false),
|
||||
nJobNumber = $bindable(''),
|
||||
nStockQty = $bindable(''),
|
||||
showNote = $bindable(false),
|
||||
addError,
|
||||
isSplit,
|
||||
addTotalKg,
|
||||
formatNumber,
|
||||
onSubmit,
|
||||
onDismissNote,
|
||||
onCancelEdit,
|
||||
composerRef = $bindable<HTMLElement | null>(null)
|
||||
}: {
|
||||
products: ThroughputProduct[];
|
||||
editingId: number | null;
|
||||
saving: boolean;
|
||||
nDate?: string;
|
||||
nProductId?: string;
|
||||
nQuantity?: string;
|
||||
nType?: ThroughputQuantityType;
|
||||
nBagSize?: string;
|
||||
nStaff?: string;
|
||||
nNotes?: string;
|
||||
nForOrder?: boolean;
|
||||
nForStock?: boolean;
|
||||
nJobNumber?: string;
|
||||
nStockQty?: string;
|
||||
showNote?: boolean;
|
||||
addError: string;
|
||||
isSplit: boolean;
|
||||
addTotalKg: number | null;
|
||||
formatNumber: (value: number | null | undefined, digits?: number) => string;
|
||||
onSubmit: () => void;
|
||||
onDismissNote: () => void;
|
||||
onCancelEdit: () => void;
|
||||
composerRef?: HTMLElement | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="composer" class:editing={editingId != null} bind:this={composerRef}>
|
||||
<div class="composer-head">
|
||||
<div class="composer-title">
|
||||
<h2>{editingId != null ? 'Edit packing run' : 'Add a packing run'}</h2>
|
||||
</div>
|
||||
{#if editingId != null}
|
||||
<button type="button" class="cancel-edit" onclick={onCancelEdit}>
|
||||
<X size={16} strokeWidth={2.4} /> Cancel edit
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<form class="add-row" onsubmit={(e) => { e.preventDefault(); onSubmit(); }}>
|
||||
<div class="add-cell">
|
||||
<span class="cell-label">Date</span>
|
||||
<input type="date" bind:value={nDate} aria-label="Production date" />
|
||||
</div>
|
||||
<div class="add-cell add-product">
|
||||
<span class="cell-label">Product</span>
|
||||
<ThroughputProductPicker {products} bind:productId={nProductId} inputId="throughput-add-product" />
|
||||
</div>
|
||||
<div class="add-cell">
|
||||
<span class="cell-label">Packed</span>
|
||||
<div class="packed-inputs">
|
||||
<input
|
||||
class="qty"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputmode="decimal"
|
||||
bind:value={nQuantity}
|
||||
placeholder={nType === 'bags' ? 'Bags' : 'Total kg'}
|
||||
aria-label={nType === 'bags' ? 'Number of bags' : 'Total kilograms'}
|
||||
/>
|
||||
<select class="unit" bind:value={nType} aria-label="Bags or kilograms">
|
||||
<option value="bags">bags</option>
|
||||
<option value="kg">kg (bulka)</option>
|
||||
</select>
|
||||
{#if nType === 'bags'}
|
||||
<span class="times" aria-hidden="true">×</span>
|
||||
<input
|
||||
class="bag"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputmode="decimal"
|
||||
bind:value={nBagSize}
|
||||
placeholder="kg/bag"
|
||||
aria-label="Kilograms per bag"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if nType === 'bags' && addTotalKg !== null}
|
||||
<span class="packed-total">= {formatNumber(addTotalKg)} kg total</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="add-cell">
|
||||
<span class="cell-label">Packed by</span>
|
||||
<input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" />
|
||||
</div>
|
||||
<div class="add-cell add-action">
|
||||
<button type="submit" class="add-entry-button" disabled={saving}>
|
||||
<Plus size={18} strokeWidth={2.6} />
|
||||
<span>{saving ? 'Saving…' : editingId != null ? 'Save' : 'Add'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="add-extra">
|
||||
{#if showNote}
|
||||
<div class="note-field">
|
||||
<input
|
||||
class="note-input"
|
||||
type="text"
|
||||
bind:value={nNotes}
|
||||
placeholder="Note (optional)"
|
||||
aria-label="Note"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="note-dismiss"
|
||||
title="Remove note"
|
||||
aria-label="Remove note"
|
||||
onclick={onDismissNote}
|
||||
>
|
||||
<X size={16} strokeWidth={2.4} />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="link-button" onclick={() => (showNote = true)}>+ Add a note</button>
|
||||
{/if}
|
||||
{#if addError}
|
||||
<span class="add-error"><TriangleAlert size={15} strokeWidth={2.4} /> {addError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.composer {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
|
||||
border-radius: 1rem;
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 16px 34px -28px rgba(15, 23, 42, 0.28);
|
||||
/* The product picker menu is absolutely positioned and needs to escape the
|
||||
card bounds without being cut off. */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.composer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.45rem 0;
|
||||
}
|
||||
|
||||
.composer-title h2 {
|
||||
margin: 0;
|
||||
font-size: 1.22rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: grid;
|
||||
grid-template-columns: 7.5rem minmax(19rem, 1.7fr) minmax(14rem, 1.15fr) minmax(10rem, 0.9fr) auto;
|
||||
gap: 0.75rem 0.85rem;
|
||||
align-items: start;
|
||||
padding: 0 1.45rem 1.25rem;
|
||||
}
|
||||
|
||||
.add-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.add-row input:not([type='checkbox']),
|
||||
.add-row select {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0.62rem 0.78rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
||||
border-radius: 0.8rem;
|
||||
font-size: 0.98rem;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.add-row input:not([type='checkbox']):focus-visible,
|
||||
.add-row select:focus-visible,
|
||||
.cancel-edit:focus-visible,
|
||||
.add-entry-button:focus-visible,
|
||||
.note-dismiss:focus-visible {
|
||||
outline: 3px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.packed-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.packed-inputs .qty {
|
||||
flex: 1 1 4.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.packed-inputs .unit {
|
||||
flex: 0 0 4.7rem;
|
||||
width: 4.7rem;
|
||||
}
|
||||
|
||||
.packed-inputs .bag {
|
||||
flex: 0 0 6rem;
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.packed-inputs .times {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.packed-total {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
color: var(--color-success);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.add-action {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.add-entry-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 48px;
|
||||
padding: 0.62rem 1.28rem;
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: 0.8rem;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
|
||||
.add-entry-button:hover:not(:disabled) {
|
||||
background: #126a33;
|
||||
}
|
||||
|
||||
.add-entry-button:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.add-extra {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
padding: 0.25rem 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--color-success);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.note-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1 1 16rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.note-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.note-dismiss,
|
||||
.cancel-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-dismiss {
|
||||
flex-shrink: 0;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
padding: 0;
|
||||
border-radius: 0.55rem;
|
||||
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.cancel-edit {
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.note-dismiss:hover,
|
||||
.cancel-edit:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-text-muted);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.add-error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: #8a1622;
|
||||
font-weight: 650;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
display: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.composer.editing {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 1px var(--color-brand) inset;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.add-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem 1rem;
|
||||
}
|
||||
|
||||
.add-action {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.add-entry-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.add-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.add-cell:nth-child(2),
|
||||
.add-cell:nth-child(3),
|
||||
.add-action {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.add-entry-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.composer-head {
|
||||
padding-left: 1.15rem;
|
||||
padding-right: 1.15rem;
|
||||
}
|
||||
|
||||
.composer-title h2 {
|
||||
font-size: 1.28rem;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.85rem 1rem;
|
||||
padding: 0 1.15rem 1.15rem;
|
||||
}
|
||||
|
||||
.add-cell .cell-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.add-cell:nth-child(2),
|
||||
.add-action {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.add-entry-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
|
||||
import type { ThroughputEntry } from '$lib/types';
|
||||
|
||||
let {
|
||||
pendingDelete,
|
||||
deletingId,
|
||||
formatDate,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
dialogRef = $bindable<HTMLElement | null>(null)
|
||||
}: {
|
||||
pendingDelete: ThroughputEntry;
|
||||
deletingId: number | null;
|
||||
formatDate: (value: string) => string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
dialogRef?: HTMLElement | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="modal-backdrop" role="presentation" onclick={onCancel}>
|
||||
<div
|
||||
class="modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-title"
|
||||
tabindex="-1"
|
||||
bind:this={dialogRef}
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => { if (event.key === 'Escape') onCancel(); }}
|
||||
>
|
||||
<div class="modal-icon"><Trash2 size={22} strokeWidth={2.2} /></div>
|
||||
<h2 id="delete-title" class="modal-title">Delete this run?</h2>
|
||||
<p class="modal-text">
|
||||
The <strong>{pendingDelete.product_name_snapshot}</strong> run from
|
||||
{formatDate(pendingDelete.production_date)} will be permanently removed.
|
||||
This cannot be undone.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="modal-cancel" onclick={onCancel}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-confirm"
|
||||
disabled={deletingId === pendingDelete.id}
|
||||
onclick={onConfirm}
|
||||
>
|
||||
{deletingId === pendingDelete.id ? 'Deleting…' : 'Delete run'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(28rem, 100%);
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
|
||||
animation: modal-pop 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.modal-card:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
border-radius: 0.8rem;
|
||||
background: #fdecee;
|
||||
color: #b3261e;
|
||||
}
|
||||
|
||||
.modal-title,
|
||||
.modal-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.modal-cancel,
|
||||
.modal-confirm {
|
||||
min-height: 44px;
|
||||
padding: 0.6rem 1.15rem;
|
||||
border-radius: 0.7rem;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.modal-cancel:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.modal-confirm {
|
||||
background: #b3261e;
|
||||
border: 1px solid #b3261e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-confirm:hover:not(:disabled) {
|
||||
background: #95201a;
|
||||
}
|
||||
|
||||
.modal-confirm:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.modal-cancel:focus-visible,
|
||||
.modal-confirm:focus-visible {
|
||||
outline: 3px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes modal-pop {
|
||||
from { opacity: 0; transform: translateY(6px) scale(0.985); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal-card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,725 @@
|
||||
<script lang="ts">
|
||||
import { ArrowUpDown, ChevronLeft, ChevronRight, History, Pencil, Search, Trash2, TriangleAlert, X } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import type { SortKey } from '$lib/components/throughput/utils';
|
||||
import type { ThroughputEntry, ThroughputProduct, ThroughputQuantityType } from '$lib/types';
|
||||
|
||||
let {
|
||||
products,
|
||||
filtersActive,
|
||||
showFilters = $bindable(false),
|
||||
dateFrom = $bindable(''),
|
||||
dateTo = $bindable(''),
|
||||
productFilter = $bindable(''),
|
||||
staffFilter = $bindable(''),
|
||||
typeFilter = $bindable<'' | ThroughputQuantityType>(''),
|
||||
isLoading,
|
||||
errorMessage,
|
||||
sortKey,
|
||||
highlightId,
|
||||
deletingId,
|
||||
sortedEntries,
|
||||
paginatedEntries,
|
||||
page = $bindable(1),
|
||||
totalPages,
|
||||
pageStart,
|
||||
pageEnd,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
packedMain,
|
||||
packedDetail,
|
||||
onApplyFilters,
|
||||
onClearFilters,
|
||||
onToggleSort,
|
||||
onStartEdit,
|
||||
onRequestDelete
|
||||
}: {
|
||||
products: ThroughputProduct[];
|
||||
filtersActive: boolean;
|
||||
showFilters?: boolean;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
productFilter?: string;
|
||||
staffFilter?: string;
|
||||
typeFilter?: '' | ThroughputQuantityType;
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
sortKey: SortKey;
|
||||
highlightId: number | null;
|
||||
deletingId: number | null;
|
||||
sortedEntries: ThroughputEntry[];
|
||||
paginatedEntries: ThroughputEntry[];
|
||||
page: number;
|
||||
totalPages: number;
|
||||
pageStart: number;
|
||||
pageEnd: number;
|
||||
formatDate: (value: string) => string;
|
||||
formatNumber: (value: number | null | undefined, digits?: number) => string;
|
||||
packedMain: (entry: ThroughputEntry) => string;
|
||||
packedDetail: (entry: ThroughputEntry) => string;
|
||||
onApplyFilters: () => void;
|
||||
onClearFilters: () => void;
|
||||
onToggleSort: (key: SortKey) => void;
|
||||
onStartEdit: (entry: ThroughputEntry) => void;
|
||||
onRequestDelete: (entry: ThroughputEntry) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="history-shell">
|
||||
<div class="log-controls">
|
||||
<div class="log-title">
|
||||
<h2 class="history-title">
|
||||
{#if !filtersActive}<History size={18} strokeWidth={2.1} />{/if}
|
||||
<span>{filtersActive ? 'Filtered entries' : 'Recent entries'}</span>
|
||||
</h2>
|
||||
<span class="log-subtitle">{filtersActive ? 'Matching runs' : 'Last 30 days'}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="find-button"
|
||||
class:active={showFilters}
|
||||
aria-expanded={showFilters}
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
>
|
||||
<Search size={18} strokeWidth={2.2} />
|
||||
<span>Find past entries</span>
|
||||
{#if filtersActive}<span class="find-dot" aria-label="filters applied"></span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showFilters}
|
||||
<form
|
||||
class="filters"
|
||||
transition:fade={{ duration: 120 }}
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onApplyFilters();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>From date</span>
|
||||
<input type="date" bind:value={dateFrom} />
|
||||
</label>
|
||||
<label>
|
||||
<span>To date</span>
|
||||
<input type="date" bind:value={dateTo} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Product</span>
|
||||
<select bind:value={productFilter}>
|
||||
<option value="">All products</option>
|
||||
{#each products as product (product.id)}
|
||||
<option value={String(product.id)}>{product.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Staff</span>
|
||||
<input type="text" placeholder="Name" bind:value={staffFilter} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Packed as</span>
|
||||
<select bind:value={typeFilter}>
|
||||
<option value="">Bags or kg</option>
|
||||
<option value="bags">Bags</option>
|
||||
<option value="kg">Kilograms</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="filter-actions">
|
||||
<button type="submit" class="apply-button" disabled={isLoading}>
|
||||
{isLoading ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
{#if filtersActive}
|
||||
<button type="button" class="clear-button" onclick={onClearFilters}>
|
||||
<X size={16} strokeWidth={2.4} /> Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error" role="alert">
|
||||
<TriangleAlert size={20} strokeWidth={2.2} />
|
||||
<span>{errorMessage}</span>
|
||||
<button type="button" class="retry-button" onclick={onApplyFilters}>Try again</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="log">
|
||||
<div class="log-head">
|
||||
<button type="button" class="sort-head" class:active={sortKey === 'date'} onclick={() => onToggleSort('date')}>
|
||||
<span>Date</span>
|
||||
<ArrowUpDown size={14} strokeWidth={2.1} />
|
||||
</button>
|
||||
<button type="button" class="sort-head" class:active={sortKey === 'product'} onclick={() => onToggleSort('product')}>
|
||||
<span>Product</span>
|
||||
<ArrowUpDown size={14} strokeWidth={2.1} />
|
||||
</button>
|
||||
<button type="button" class="sort-head" class:active={sortKey === 'packed'} onclick={() => onToggleSort('packed')}>
|
||||
<span>Packed</span>
|
||||
<ArrowUpDown size={14} strokeWidth={2.1} />
|
||||
</button>
|
||||
<button type="button" class="sort-head" class:active={sortKey === 'total'} onclick={() => onToggleSort('total')}>
|
||||
<span>Total kg</span>
|
||||
<ArrowUpDown size={14} strokeWidth={2.1} />
|
||||
</button>
|
||||
<button type="button" class="sort-head" class:active={sortKey === 'staff'} onclick={() => onToggleSort('staff')}>
|
||||
<span>Packed by</span>
|
||||
<ArrowUpDown size={14} strokeWidth={2.1} />
|
||||
</button>
|
||||
<button type="button" class="sort-head col-notes-head" class:active={sortKey === 'notes'} onclick={() => onToggleSort('notes')}>
|
||||
<span>Notes</span>
|
||||
<ArrowUpDown size={14} strokeWidth={2.1} />
|
||||
</button>
|
||||
<span class="col-actions-head">Edit</span>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div class="row row-skeleton" aria-hidden="true">
|
||||
<span class="sk sk-date"></span>
|
||||
<span class="sk sk-product"></span>
|
||||
<span class="sk sk-packed"></span>
|
||||
<span class="sk sk-staff"></span>
|
||||
<span class="sk sk-qa"></span>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each paginatedEntries as entry (entry.id)}
|
||||
<div class="row" class:just-added={entry.id === highlightId}>
|
||||
<span class="col-date">
|
||||
<span class="cell-label">Date</span>
|
||||
{formatDate(entry.production_date)}
|
||||
</span>
|
||||
<span class="col-product">
|
||||
<span class="cell-label">Product</span>
|
||||
<span class="product-name">{entry.product_name_snapshot}</span>
|
||||
</span>
|
||||
<span class="col-packed">
|
||||
<span class="cell-label">Packed</span>
|
||||
<span class="packed-main">{packedMain(entry)}</span>
|
||||
<span class="packed-detail">{packedDetail(entry)}</span>
|
||||
</span>
|
||||
<span class="col-total">
|
||||
<span class="cell-label">Total kg</span>
|
||||
<span class="total-kg">{formatNumber(entry.calculated_kg)} kg</span>
|
||||
</span>
|
||||
<span class="col-staff">
|
||||
<span class="cell-label">Packed by</span>
|
||||
{entry.staff_name ?? '—'}
|
||||
</span>
|
||||
<span class="col-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="row-action"
|
||||
title="Edit this run"
|
||||
aria-label="Edit this run"
|
||||
onclick={() => onStartEdit(entry)}
|
||||
>
|
||||
<Pencil size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="row-action row-action-danger"
|
||||
title="Delete this run"
|
||||
aria-label="Delete this run"
|
||||
disabled={deletingId === entry.id}
|
||||
onclick={() => onRequestDelete(entry)}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</span>
|
||||
{#if entry.notes}
|
||||
<p class="row-notes"><span class="cell-label">Note</span>{entry.notes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
{#if filtersActive}
|
||||
<p class="empty-title">No entries match your search</p>
|
||||
<p class="empty-help">Try a wider date range, or clear the filters to see everything.</p>
|
||||
<button type="button" class="clear-button" onclick={onClearFilters}>
|
||||
<X size={16} strokeWidth={2.4} /> Clear filters
|
||||
</button>
|
||||
{:else}
|
||||
<p class="empty-title">No packing logged yet</p>
|
||||
<p class="empty-help">Use the inline entry area above to add your first run. It appears here immediately, newest first.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sortedEntries.length > 0}
|
||||
<div class="pagination">
|
||||
<p class="pagination-summary">{pageStart}-{pageEnd} of {sortedEntries.length}</p>
|
||||
<div class="pagination-actions">
|
||||
<button type="button" class="page-button" disabled={page === 1} onclick={() => (page = Math.max(1, page - 1))}>
|
||||
<ChevronLeft size={16} strokeWidth={2.2} />
|
||||
<span>Previous</span>
|
||||
</button>
|
||||
<span class="page-indicator">Page {page} of {totalPages}</span>
|
||||
<button type="button" class="page-button" disabled={page === totalPages} onclick={() => (page = Math.min(totalPages, page + 1))}>
|
||||
<span>Next</span>
|
||||
<ChevronRight size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.history-shell {
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.log-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
font-size: 1.16rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.log-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.find-button,
|
||||
.apply-button,
|
||||
.clear-button,
|
||||
.retry-button,
|
||||
.sort-head,
|
||||
.page-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.find-button {
|
||||
position: relative;
|
||||
min-height: 44px;
|
||||
padding: 0.6rem 0.95rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
|
||||
border-radius: 0.78rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.find-button.active {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.find-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
|
||||
gap: 0.8rem;
|
||||
align-items: end;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.95rem;
|
||||
background: color-mix(in srgb, var(--color-bg-surface) 82%, white);
|
||||
}
|
||||
|
||||
.filters label {
|
||||
display: grid;
|
||||
gap: 0.32rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.filters input,
|
||||
.filters select {
|
||||
min-height: 44px;
|
||||
padding: 0.58rem 0.72rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 12%, var(--color-border));
|
||||
border-radius: 0.78rem;
|
||||
font-size: 0.97rem;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.apply-button,
|
||||
.clear-button,
|
||||
.retry-button,
|
||||
.page-button {
|
||||
min-height: 42px;
|
||||
padding: 0.55rem 0.82rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.72rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.apply-button {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.95rem 1rem;
|
||||
border: 1px solid #efc6c2;
|
||||
border-radius: 0.9rem;
|
||||
background: #fff4f2;
|
||||
color: #8a1622;
|
||||
}
|
||||
|
||||
.log {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--color-bg-surface);
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.log-head,
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 4.8rem;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
padding: 0.9rem 1.45rem;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
background: color-mix(in srgb, var(--color-bg-app) 55%, var(--color-bg-surface));
|
||||
}
|
||||
|
||||
.sort-head {
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sort-head.active,
|
||||
.sort-head:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.row {
|
||||
padding: 1rem 1.45rem;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.row.just-added {
|
||||
animation: flash-in 1.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes flash-in {
|
||||
0% { background: var(--color-brand-tint); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
.col-product,
|
||||
.col-packed,
|
||||
.col-total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.product-name,
|
||||
.packed-main,
|
||||
.total-kg {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.packed-detail {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.total-kg,
|
||||
.packed-main {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.col-actions-head {
|
||||
justify-self: end;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
|
||||
.row-action:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text-primary);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.row-action-danger:hover {
|
||||
border-color: #e2a8af;
|
||||
color: #b3261e;
|
||||
background: #fdecee;
|
||||
}
|
||||
|
||||
.row-action:disabled,
|
||||
.page-button:disabled,
|
||||
.apply-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.row-notes {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0.35rem 0 0;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px dashed var(--color-divider);
|
||||
font-size: 0.94rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
display: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.col-notes-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-skeleton {
|
||||
padding: 1.15rem 1.45rem;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.sk {
|
||||
height: 1.1rem;
|
||||
border-radius: 0.4rem;
|
||||
background: linear-gradient(90deg, #eef1f4 25%, #f6f8fa 50%, #eef1f4 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sk-staff { width: 70%; }
|
||||
.sk-qa { width: 6rem; height: 1.7rem; border-radius: 999px; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 650;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.empty-help {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem 1.45rem 1.15rem;
|
||||
border-top: 1px solid var(--color-divider);
|
||||
background: color-mix(in srgb, var(--color-bg-app) 48%, var(--color-bg-surface));
|
||||
}
|
||||
|
||||
.pagination-summary,
|
||||
.page-indicator {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pagination-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.log-controls,
|
||||
.filters,
|
||||
.error {
|
||||
padding-left: 1.15rem;
|
||||
padding-right: 1.15rem;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 1.28rem;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 1rem 1.15rem;
|
||||
}
|
||||
|
||||
.col-product,
|
||||
.row-notes {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
display: block;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--color-divider);
|
||||
}
|
||||
|
||||
.row-action {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
padding-left: 1.15rem;
|
||||
padding-right: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.log-head,
|
||||
.row {
|
||||
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) minmax(0, 1.2fr) 4.8rem;
|
||||
}
|
||||
|
||||
.col-notes-head {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.row-notes {
|
||||
grid-column: 6;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
grid-column: 7;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row.just-added,
|
||||
.sk {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -61,6 +61,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Reflect a selection set from outside (e.g. when an entry is loaded into the
|
||||
// composer to be edited) so the search box shows the chosen product, not blank.
|
||||
$effect(() => {
|
||||
if (productId && !focused) {
|
||||
const match = products.find((p) => String(p.id) === productId);
|
||||
if (match) query = label(match);
|
||||
}
|
||||
});
|
||||
|
||||
// If the active client no longer contains the selected product, drop it.
|
||||
$effect(() => {
|
||||
if (selected && clientName && (selected.client_name ?? '') !== clientName) {
|
||||
@@ -120,20 +129,18 @@
|
||||
</script>
|
||||
|
||||
<div class="picker" bind:this={root} onfocusin={() => (focused = true)} onfocusout={onFocusOut}>
|
||||
<div class="client-row">
|
||||
<label class="client-label" for={`${inputId}-client`}>Client</label>
|
||||
<select
|
||||
id={`${inputId}-client`}
|
||||
class="client-select"
|
||||
bind:value={clientName}
|
||||
{disabled}
|
||||
>
|
||||
<option value="">All clients</option>
|
||||
{#each clients as client (client)}
|
||||
<option value={client}>{client}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
id={`${inputId}-client`}
|
||||
class="client-select"
|
||||
bind:value={clientName}
|
||||
aria-label="Filter by client"
|
||||
{disabled}
|
||||
>
|
||||
<option value="">All clients</option>
|
||||
{#each clients as client (client)}
|
||||
<option value={client}>{client}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<div class="combo" role="combobox" aria-expanded={open} aria-haspopup="listbox" aria-controls={`${inputId}-list`}>
|
||||
<span class="combo-icon" aria-hidden="true"><Search size={16} strokeWidth={2.2} /></span>
|
||||
@@ -196,30 +203,23 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Client filter and product search sit side by side so the product is never
|
||||
stacked underneath the client. They wrap to two rows only when the cell is
|
||||
too narrow to keep both readable. */
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.client-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.client-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.client-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
flex: 0 1 8.5rem;
|
||||
min-width: 6rem;
|
||||
min-height: 48px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 0.55rem;
|
||||
font: inherit;
|
||||
background: var(--color-bg-surface, #fff);
|
||||
color: var(--color-text-primary, #111827);
|
||||
@@ -228,8 +228,17 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.picker {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.client-select {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
.combo-icon {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
@@ -276,7 +285,7 @@
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
z-index: 200;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2 } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import type { ConfettiPiece } from '$lib/components/throughput/utils';
|
||||
|
||||
let { confetti }: { confetti: ConfettiPiece[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="success-overlay" role="presentation" transition:fade={{ duration: 160 }}>
|
||||
<div class="success-card" role="status" aria-live="polite">
|
||||
<div class="confetti" aria-hidden="true">
|
||||
{#each confetti as piece, i (i)}
|
||||
<span
|
||||
class="confetti-piece"
|
||||
style="left:{piece.left}%; width:{piece.size}px; height:{piece.size}px; background:{piece.color}; animation-delay:{piece.delay}s; animation-duration:{piece.duration}s; --rot:{piece.rotate}deg; --drift:{piece.drift}px;"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="success-icon"><CheckCircle2 size={44} strokeWidth={2.4} /></span>
|
||||
<p class="success-title">Added</p>
|
||||
<p class="success-text">Your packing run has been added to the log.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.success-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.success-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: min(30rem, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 2.6rem 2.8rem 2.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 28px 70px -18px rgba(0, 0, 0, 0.45);
|
||||
text-align: center;
|
||||
animation: success-pop 240ms cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4.2rem;
|
||||
height: 4.2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.success-title,
|
||||
.success-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.confetti {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.confetti-piece {
|
||||
position: absolute;
|
||||
top: -12%;
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
animation-name: confetti-fall;
|
||||
animation-timing-function: ease-in;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes success-pop {
|
||||
from { opacity: 0; transform: scale(0.82); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes confetti-fall {
|
||||
0% { transform: translate(0, 0) rotate(0deg); opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
100% { transform: translate(var(--drift), 340px) rotate(var(--rot)); opacity: 0.9; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.success-card {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.confetti {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
today,
|
||||
weekRangeLabel,
|
||||
heroStats,
|
||||
mixTotals,
|
||||
formatDate,
|
||||
formatNumber
|
||||
}: {
|
||||
today: string;
|
||||
weekRangeLabel: string;
|
||||
heroStats: { today: number; thisWeek: number; avgFourWeek: number };
|
||||
mixTotals: { horse: number; grain: number };
|
||||
formatDate: (value: string) => string;
|
||||
formatNumber: (value: number | null | undefined, digits?: number) => string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<header class="throughput-summary" aria-label="Throughput summary">
|
||||
<div class="summary-heading">
|
||||
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
|
||||
<h2>Throughput Overview</h2>
|
||||
</div>
|
||||
<dl class="mix-facts" aria-label="Throughput by customer today">
|
||||
<div class="fact">
|
||||
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
|
||||
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
|
||||
<p class="fact-sub">PHF Horsemix · {formatDate(today)}</p>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
|
||||
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
|
||||
<p class="fact-sub">All other customers · {formatDate(today)}</p>
|
||||
</div>
|
||||
</dl>
|
||||
<dl class="facts">
|
||||
<div class="fact">
|
||||
<dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt>
|
||||
<dd>{formatNumber(heroStats.today)} <span class="fact-unit">kg</span></dd>
|
||||
<p class="fact-sub">{formatDate(today)}</p>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<dt><span class="fact-icon"><CalendarRange size={16} strokeWidth={2.2} /></span>This week</dt>
|
||||
<dd>{formatNumber(heroStats.thisWeek)} <span class="fact-unit">kg</span></dd>
|
||||
<p class="fact-sub">{weekRangeLabel}</p>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<dt><span class="fact-icon"><TrendingUp size={16} strokeWidth={2.2} /></span>4-week average</dt>
|
||||
<dd>{formatNumber(heroStats.avgFourWeek)} <span class="fact-unit">kg</span></dd>
|
||||
<p class="fact-sub">Per week, last 4 weeks</p>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.throughput-summary {
|
||||
display: grid;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-border));
|
||||
border-radius: 0.95rem;
|
||||
background:
|
||||
radial-gradient(circle at top right, color-mix(in srgb, var(--color-brand) 12%, transparent), transparent 45%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--color-brand) 5%, var(--color-bg-surface)), var(--color-bg-surface));
|
||||
}
|
||||
|
||||
.summary-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 0.75rem;
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.summary-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.facts,
|
||||
.mix-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Mix cards lead the overview, so they carry the top padding; the hero
|
||||
figures follow and hug up against them. */
|
||||
.mix-facts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.facts {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.fact {
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
min-height: 7rem;
|
||||
padding: 1rem 1rem 0.9rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 12%, var(--color-border));
|
||||
border-radius: 0.95rem;
|
||||
background: color-mix(in srgb, var(--color-bg-surface) 84%, white);
|
||||
box-shadow: 0 12px 36px -28px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.fact dt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.fact-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.fact dd {
|
||||
margin: 0;
|
||||
font-size: clamp(1.85rem, 4.5cqi, 2.3rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--color-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.fact-unit {
|
||||
font-size: 0.45em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.fact-sub {
|
||||
margin: 0.05rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.throughput-summary {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.facts,
|
||||
.mix-facts {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.summary-heading {
|
||||
grid-column: 1 / -1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fact {
|
||||
min-height: 7rem;
|
||||
padding: 0.9rem 0.85rem;
|
||||
}
|
||||
|
||||
.fact-sub {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.mix-facts {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.facts {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0 0.9rem 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ThroughputEntry } from '$lib/types';
|
||||
|
||||
export type SortKey = 'date' | 'product' | 'packed' | 'total' | 'staff' | 'destination' | 'notes';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export type ConfettiPiece = {
|
||||
left: number;
|
||||
delay: number;
|
||||
duration: number;
|
||||
color: string;
|
||||
rotate: number;
|
||||
drift: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444'];
|
||||
|
||||
export function compareText(a: string | null | undefined, b: string | null | undefined) {
|
||||
return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function compareDate(a: string | null | undefined, b: string | null | undefined) {
|
||||
const aTime = a ? Date.parse(a) : Number.NEGATIVE_INFINITY;
|
||||
const bTime = b ? Date.parse(b) : Number.NEGATIVE_INFINITY;
|
||||
return aTime - bTime;
|
||||
}
|
||||
|
||||
export function toISODate(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ausToday(): Date {
|
||||
const ymd = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Australia/Sydney',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).format(new Date());
|
||||
const [y, m, d] = ymd.split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export function startOfWeekMonday(d: Date): Date {
|
||||
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const dow = (start.getDay() + 6) % 7;
|
||||
start.setDate(start.getDate() - dow);
|
||||
return start;
|
||||
}
|
||||
|
||||
export function addDays(d: Date, days: number): Date {
|
||||
const next = new Date(d);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildConfetti(colors = CONFETTI_COLORS): ConfettiPiece[] {
|
||||
return Array.from({ length: 42 }, (_, i) => ({
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 1.2,
|
||||
duration: 1 + Math.random() * 0.8,
|
||||
color: colors[i % colors.length],
|
||||
rotate: 220 + Math.random() * 360,
|
||||
drift: (Math.random() - 0.5) * 60,
|
||||
size: 6 + Math.random() * 6
|
||||
}));
|
||||
}
|
||||
|
||||
export function isStockEntry(entry: ThroughputEntry): boolean {
|
||||
return entry.for_stock || (!entry.for_order && /stock/i.test(entry.notes ?? ''));
|
||||
}
|
||||
@@ -2,12 +2,21 @@ import {
|
||||
BadgeDollarSign,
|
||||
Calculator,
|
||||
ClipboardPenLine,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
ListOrdered,
|
||||
Package,
|
||||
Plug,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
TrendingUp
|
||||
SlidersHorizontal,
|
||||
Settings,
|
||||
Tags,
|
||||
TrendingUp,
|
||||
Users
|
||||
} from 'lucide-svelte';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
@@ -31,6 +40,18 @@ export type NavItem = {
|
||||
icon: ComponentType;
|
||||
moduleKey?: string;
|
||||
badge?: string;
|
||||
/**
|
||||
* Highlight this row only on an exact pathname match instead of a prefix
|
||||
* match. Needed for parent routes like `/ordering/manage` that are a prefix
|
||||
* of their siblings (`/ordering/manage/products`).
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* Optional third-level submenu. A child with `children` renders as its own
|
||||
* collapsible row inside a group (e.g. Integrations → Xero). The row stays a
|
||||
* link to its own `href`; a chevron toggles the nested list.
|
||||
*/
|
||||
children?: NavItem[];
|
||||
};
|
||||
|
||||
export type FooterLink = {
|
||||
@@ -50,6 +71,14 @@ export type NavGroup = {
|
||||
label: string;
|
||||
icon: ComponentType;
|
||||
children: NavItem[];
|
||||
/**
|
||||
* When set, the group header is itself a link (clicking it navigates here)
|
||||
* while a separate chevron still toggles the child list. Used by Order
|
||||
* Management: clicking the header lands on the order queue.
|
||||
*/
|
||||
href?: string;
|
||||
/** Exact-match the header link's active state (see NavItem.exact). */
|
||||
exact?: boolean;
|
||||
};
|
||||
|
||||
/** The rail is a sequence of standalone items and collapsible groups. */
|
||||
@@ -62,6 +91,12 @@ export type Crumb = {
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type PageMeta = {
|
||||
title: string;
|
||||
category: string;
|
||||
icon: ComponentType;
|
||||
};
|
||||
|
||||
export const dashboardItem: NavItem = {
|
||||
href: '/',
|
||||
label: 'Dashboard',
|
||||
@@ -96,6 +131,15 @@ export const editorItem: NavItem = {
|
||||
badge: 'test'
|
||||
};
|
||||
|
||||
export const ingredientsEditorItem: NavItem = {
|
||||
href: '/ingredients',
|
||||
label: 'Ingredients Editor',
|
||||
shortLabel: 'IE',
|
||||
icon: FlaskConical,
|
||||
moduleKey: 'products',
|
||||
badge: 'test'
|
||||
};
|
||||
|
||||
export const reportingItem: NavItem = {
|
||||
href: '/reporting',
|
||||
label: 'Reporting',
|
||||
@@ -109,8 +153,7 @@ export const throughputItem: NavItem = {
|
||||
label: 'Throughput',
|
||||
shortLabel: 'OT',
|
||||
icon: Gauge,
|
||||
moduleKey: 'operations_throughput',
|
||||
badge: 'test'
|
||||
moduleKey: 'operations_throughput'
|
||||
};
|
||||
|
||||
export const orderingItem: NavItem = {
|
||||
@@ -121,6 +164,37 @@ export const orderingItem: NavItem = {
|
||||
moduleKey: 'ordering'
|
||||
};
|
||||
|
||||
/** Third-level submenu under Integrations. Each connected system is its own row. */
|
||||
export const integrationsChildren: NavItem[] = [
|
||||
{ href: '/ordering/manage/integrations/xero', label: 'Xero', shortLabel: 'XE', icon: Link2, moduleKey: 'ordering' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Children of the internal "Order Management" family. The first entry points at
|
||||
* the management root (`/ordering/manage`) which renders the order queue, so it
|
||||
* needs `exact` matching to avoid lighting up on its sibling routes. Integrations
|
||||
* is itself a parent: it links to the integrations landing and expands to its
|
||||
* connected systems (Xero) as a third sidebar layer.
|
||||
*/
|
||||
export const orderingManageChildren: NavItem[] = [
|
||||
{ href: '/ordering/manage', label: 'Orders', shortLabel: 'OQ', icon: ListOrdered, moduleKey: 'ordering', exact: true },
|
||||
{ href: '/ordering/manage/products', label: 'Products', shortLabel: 'PR', icon: Package, moduleKey: 'ordering' },
|
||||
{ href: '/ordering/manage/customers', label: 'Customers', shortLabel: 'CU', icon: Users, moduleKey: 'ordering' },
|
||||
{ href: '/ordering/manage/pricing', label: 'Pricing', shortLabel: 'PX', icon: Tags, moduleKey: 'ordering' },
|
||||
{ href: '/ordering/manage/settings', label: 'Settings', shortLabel: 'ST', icon: SlidersHorizontal, moduleKey: 'ordering' },
|
||||
{ href: '/ordering/manage/integrations', label: 'Integrations', shortLabel: 'IN', icon: Plug, moduleKey: 'ordering', exact: true, children: integrationsChildren }
|
||||
];
|
||||
|
||||
/** The collapsible Order Management family for internal staff. */
|
||||
export const orderingManageGroup: NavGroup = {
|
||||
id: 'ordering',
|
||||
label: 'Order Management',
|
||||
icon: ShoppingCart,
|
||||
href: '/ordering/manage',
|
||||
exact: true,
|
||||
children: orderingManageChildren
|
||||
};
|
||||
|
||||
export const workingDocumentItems: NavItem[] = [
|
||||
// Mix Master remains available through the existing route and access logic,
|
||||
// but is temporarily hidden from the sidebar.
|
||||
@@ -140,6 +214,7 @@ export const clientNavigationItems: NavItem[] = [
|
||||
productCostingItem,
|
||||
throughputItem,
|
||||
editorItem,
|
||||
ingredientsEditorItem,
|
||||
accessControlItem
|
||||
];
|
||||
|
||||
@@ -155,8 +230,14 @@ export const baseSearchItems: SearchItem[] = [
|
||||
{
|
||||
href: '/editor',
|
||||
label: 'Open Mix Editor',
|
||||
description: 'Edit client, product, and mix naming from one table.',
|
||||
keywords: 'editor products mixes clients names bulk table phf horse manning'
|
||||
description: 'Edit mix names, status, and ingredients from one table.',
|
||||
keywords: 'editor mixes clients names status ingredients recipe table phf horse manning'
|
||||
},
|
||||
{
|
||||
href: '/ingredients',
|
||||
label: 'Open Ingredients Editor',
|
||||
description: 'Curate the raw material ingredients available to mixes.',
|
||||
keywords: 'ingredients editor raw materials supplier unit kg per unit catalogue mixes'
|
||||
},
|
||||
{
|
||||
href: '/',
|
||||
@@ -211,15 +292,15 @@ export const baseSearchItems: SearchItem[] = [
|
||||
* Callers pass only the modules the current session may see; empty families
|
||||
* collapse away so a role with one costing tool never gets an empty group.
|
||||
*
|
||||
* Workflow-family layout: Dashboard, then a "Costing" group (the calculator,
|
||||
* costing, editor, and master tools), then Operations and Insights modules at
|
||||
* the top level until each grows into a family of its own.
|
||||
* Workflow-family layout: Dashboard, then an "Operations" group (the mix
|
||||
* calculator and throughput) and a "Costing" group (product costing, mix and
|
||||
* ingredient editors, and master tools), then Ordering and Insights modules.
|
||||
*/
|
||||
export function buildClientNavEntries(visible: {
|
||||
dashboard?: NavItem | null;
|
||||
operations: NavItem[];
|
||||
costing: NavItem[];
|
||||
throughput?: NavItem | null;
|
||||
ordering?: NavItem | null;
|
||||
ordering?: NavEntry | null;
|
||||
reporting?: NavItem | null;
|
||||
}): NavEntry[] {
|
||||
const entries: NavEntry[] = [];
|
||||
@@ -228,19 +309,22 @@ export function buildClientNavEntries(visible: {
|
||||
entries.push({ kind: 'item', item: visible.dashboard });
|
||||
}
|
||||
|
||||
if (visible.operations.length) {
|
||||
entries.push({
|
||||
kind: 'group',
|
||||
group: { id: 'operations', label: 'Operations', icon: Layers, children: visible.operations }
|
||||
});
|
||||
}
|
||||
|
||||
if (visible.costing.length) {
|
||||
entries.push({
|
||||
kind: 'group',
|
||||
group: { id: 'costing', label: 'Costing', icon: Layers, children: visible.costing }
|
||||
group: { id: 'costing', label: 'Costing', icon: BadgeDollarSign, children: visible.costing }
|
||||
});
|
||||
}
|
||||
|
||||
if (visible.ordering) {
|
||||
entries.push({ kind: 'item', item: visible.ordering });
|
||||
}
|
||||
|
||||
if (visible.throughput) {
|
||||
entries.push({ kind: 'item', item: visible.throughput });
|
||||
entries.push(visible.ordering);
|
||||
}
|
||||
|
||||
if (visible.reporting) {
|
||||
@@ -250,17 +334,122 @@ export function buildClientNavEntries(visible: {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** True when any of a group's children matches the current route. */
|
||||
export function groupHasActiveChild(group: NavGroup, pathname: string) {
|
||||
return group.children.some((child) => matchesRoute(child.href, pathname));
|
||||
/** True when a row or any of its nested children matches the current route. */
|
||||
function itemOrChildActive(item: NavItem, pathname: string): boolean {
|
||||
if (matchesRoute(item.href, pathname, item.exact)) return true;
|
||||
return item.children?.some((child) => matchesRoute(child.href, pathname, child.exact)) ?? false;
|
||||
}
|
||||
|
||||
export function matchesRoute(href: string, pathname: string) {
|
||||
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||
/** True when any of a group's children (or grandchildren) matches the route. */
|
||||
export function groupHasActiveChild(group: NavGroup, pathname: string) {
|
||||
return group.children.some((child) => itemOrChildActive(child, pathname));
|
||||
}
|
||||
|
||||
export function matchesRoute(href: string, pathname: string, exact = false) {
|
||||
if (href === '/') return pathname === '/';
|
||||
if (exact) return pathname === href;
|
||||
return pathname.startsWith(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deepest Order Management section row for a path, descending into
|
||||
* third-level submenus (Integrations → Xero) so headers and breadcrumbs name the
|
||||
* actual page rather than the parent. Returns null for the management root.
|
||||
*/
|
||||
export function findOrderingSection(pathname: string): NavItem | null {
|
||||
for (const child of orderingManageChildren) {
|
||||
// Check grandchildren first so a nested page (Xero) wins over its parent.
|
||||
for (const grandchild of child.children ?? []) {
|
||||
if (matchesRoute(grandchild.href, pathname, grandchild.exact)) return grandchild;
|
||||
}
|
||||
if (matchesRoute(child.href, pathname, child.exact)) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pageTitle(pathname: string) {
|
||||
return clientNavigationItems.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Dashboard';
|
||||
return pageMeta(pathname).title;
|
||||
}
|
||||
|
||||
export function pageCategory(pathname: string) {
|
||||
return pageMeta(pathname).category;
|
||||
}
|
||||
|
||||
export function pageMeta(pathname: string): PageMeta {
|
||||
if (pathname === '/') {
|
||||
return { title: 'Dashboard', category: 'Overview', icon: dashboardItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/ordering/manage')) {
|
||||
const section = findOrderingSection(pathname);
|
||||
return {
|
||||
title: section?.label ?? 'Orders',
|
||||
category: 'Order Management',
|
||||
icon: section?.icon ?? orderingManageGroup.icon
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/ordering')) {
|
||||
return { title: 'Ordering', category: 'Ordering', icon: orderingItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/throughput/add')) {
|
||||
return { title: 'Add Entry', category: 'Operations', icon: throughputItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/throughput')) {
|
||||
return { title: throughputItem.label, category: 'Operations', icon: throughputItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/reporting')) {
|
||||
return { title: reportingItem.label, category: 'Insights', icon: reportingItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/mix-calculator')) {
|
||||
return { title: mixCalculatorItem.label, category: 'Operations', icon: mixCalculatorItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/product-costing')) {
|
||||
return { title: productCostingItem.label, category: 'Costing', icon: productCostingItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/editor')) {
|
||||
return { title: editorItem.label, category: 'Costing', icon: editorItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/ingredients')) {
|
||||
return { title: ingredientsEditorItem.label, category: 'Costing', icon: ingredientsEditorItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/raw-materials')) {
|
||||
return { title: 'Raw Materials', category: 'Operations', icon: FlaskConical };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/products')) {
|
||||
return { title: 'Products', category: 'Operations', icon: Package };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/mixes/new')) {
|
||||
return { title: 'New Mix', category: 'Operations', icon: ClipboardPenLine };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/mixes')) {
|
||||
return { title: 'Mix Master', category: 'Operations', icon: Layers };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/scenarios')) {
|
||||
return { title: 'Scenarios', category: 'Operations', icon: Layers };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/client-access')) {
|
||||
return { title: accessControlItem.label, category: 'Administration', icon: accessControlItem.icon };
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/settings')) {
|
||||
return { title: 'Settings', category: 'Workspace', icon: Settings };
|
||||
}
|
||||
|
||||
return { title: 'Workspace', category: 'Workspace', icon: LayoutDashboard };
|
||||
}
|
||||
|
||||
export function clientBreadcrumbs(pathname: string, session?: AppSession | null): Crumb[] {
|
||||
@@ -297,6 +486,20 @@ export function clientBreadcrumbs(pathname: string, session?: AppSession | null)
|
||||
return base;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/ordering/manage')) {
|
||||
const section = findOrderingSection(pathname);
|
||||
if (!section || section.href === '/ordering/manage') {
|
||||
return [...crumbs, { label: 'Order Management' }];
|
||||
}
|
||||
const chain: Crumb[] = [...crumbs, { label: 'Order Management', href: '/ordering/manage' }];
|
||||
// When the section is a nested grandchild (e.g. Xero), include its parent
|
||||
// (Integrations) as an intermediate crumb.
|
||||
const parent = orderingManageChildren.find((c) => c.children?.includes(section));
|
||||
if (parent) chain.push({ label: parent.label, href: parent.href });
|
||||
chain.push({ label: section.label });
|
||||
return chain;
|
||||
}
|
||||
|
||||
const sectionMap: Record<string, string> = {
|
||||
'/raw-materials': 'Raw Materials',
|
||||
'/product-costing': 'Product Costing',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Shared formatting helpers for the ordering management console. Kept tiny and
|
||||
// dependency-free so each split route page (orders, products, pricing, …) can
|
||||
// import them instead of re-declaring the same money/label functions.
|
||||
|
||||
const AUD = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
|
||||
|
||||
/** Format a value as AUD, or an em dash when null/undefined. */
|
||||
export function money(value: number | null | undefined): string {
|
||||
if (value == null) return '—';
|
||||
return AUD.format(value);
|
||||
}
|
||||
|
||||
/** Turn a snake_case status/category key into Title Case for display. */
|
||||
export function label(value: string): string {
|
||||
return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Order workflow statuses, in the order they typically progress. */
|
||||
export const ORDER_STATUSES = [
|
||||
'submitted',
|
||||
'under_review',
|
||||
'confirmed',
|
||||
'sent_to_xero',
|
||||
'in_production',
|
||||
'ready_for_pickup',
|
||||
'dispatched',
|
||||
'completed',
|
||||
'cancelled'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Map an order / customer status to a pill tone class so states are scannable
|
||||
* at a glance: warn = needs attention, info = in flight, pos = done/active,
|
||||
* danger = cancelled. Unknown values fall back to the neutral base pill.
|
||||
*/
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
submitted: 'warn',
|
||||
under_review: 'warn',
|
||||
confirmed: 'pos',
|
||||
completed: 'pos',
|
||||
active: 'pos',
|
||||
cancelled: 'danger',
|
||||
disabled: 'danger',
|
||||
suspended: 'danger',
|
||||
sent_to_xero: 'info',
|
||||
in_production: 'info',
|
||||
ready_for_pickup: 'info',
|
||||
dispatched: 'info'
|
||||
};
|
||||
|
||||
export function statusTone(value: string): string {
|
||||
return STATUS_TONE[value] ?? '';
|
||||
}
|
||||
|
||||
/** Catalogue product categories. */
|
||||
export const PRODUCT_CATEGORIES = [
|
||||
'grains',
|
||||
'premixed',
|
||||
'bags',
|
||||
'bulk_loads',
|
||||
'custom_blends',
|
||||
'services'
|
||||
] as const;
|
||||
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* Shared styles for the Order Management family. Imported once by
|
||||
* routes/ordering/manage/+layout.svelte (so the rules are global) but every
|
||||
* selector is scoped under `.manage-shell` — the layout wrapper that contains
|
||||
* all child route markup — so nothing leaks into the rest of the app.
|
||||
*
|
||||
* Everything resolves from the design tokens in $lib/styles/theme.css so the
|
||||
* console re-themes (light/dark) automatically and stays visually consistent
|
||||
* with the rest of the app. No hard-coded colours.
|
||||
*/
|
||||
|
||||
.manage-shell h2 { margin: 0 0 0.75rem; font-size: 1.02rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.manage-shell h3 { margin: 0 0 0.45rem; font-size: 0.86rem; font-weight: 700; color: var(--color-text-secondary); }
|
||||
.manage-shell .mt { margin-top: 1.15rem; }
|
||||
.manage-shell .muted { color: var(--color-text-muted); font-size: 0.84rem; margin: 0 0 0.65rem; }
|
||||
.manage-shell .muted a { color: var(--color-brand); font-weight: 600; }
|
||||
.manage-shell .muted a:hover { text-decoration: underline; }
|
||||
|
||||
.manage-shell .surface-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--color-bg-surface);
|
||||
padding: var(--space-card);
|
||||
}
|
||||
|
||||
.manage-shell .split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ── Tables: compact console rows, divider-separated ────────── */
|
||||
.manage-shell table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
.manage-shell th,
|
||||
.manage-shell td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--color-divider); }
|
||||
.manage-shell tr:last-child td { border-bottom: none; }
|
||||
.manage-shell th {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
.manage-shell tbody tr { transition: background-color 140ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||||
.manage-shell tbody tr:hover { background: var(--color-surface-hover); }
|
||||
.manage-shell tbody tr.selected { background: var(--color-surface-selected); }
|
||||
.manage-shell table.clickable tbody tr { cursor: pointer; }
|
||||
|
||||
/* ── Status pills: neutral by default, tinted by tone ──────── */
|
||||
.manage-shell .pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
background: color-mix(in srgb, var(--panel-soft) 70%, var(--color-bg-surface));
|
||||
}
|
||||
.manage-shell .pill.pos { color: var(--color-success-text); background: var(--color-success-tint); }
|
||||
.manage-shell .pill.warn { color: var(--color-warning-text); background: var(--color-warning-tint); }
|
||||
.manage-shell .pill.info { color: var(--color-info); background: var(--color-info-tint); }
|
||||
.manage-shell .pill.danger { color: var(--color-error); background: color-mix(in srgb, var(--color-error) 14%, var(--color-bg-surface)); }
|
||||
|
||||
.manage-shell .tag {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 600;
|
||||
padding: 0.08rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--color-warning-tint);
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
.manage-shell .empty { color: var(--color-text-muted); font-size: 0.85rem; margin: 0.2rem 0; }
|
||||
|
||||
/* ── Forms ──────────────────────────────────────────────────── */
|
||||
.manage-shell .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem 0.65rem; align-items: end; }
|
||||
.manage-shell .form-grid .full { grid-column: 1 / -1; }
|
||||
.manage-shell .form-grid label,
|
||||
.manage-shell .form-row label { display: grid; gap: 0.28rem; font-size: 0.76rem; font-weight: 600; color: var(--color-text-secondary); }
|
||||
.manage-shell .form-row { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; margin-bottom: 0.6rem; }
|
||||
|
||||
.manage-shell .form-row input,
|
||||
.manage-shell .form-row select,
|
||||
.manage-shell .form-grid input,
|
||||
.manage-shell .form-grid select,
|
||||
.manage-shell .actions select,
|
||||
.manage-shell .inline,
|
||||
.manage-shell .ovr {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1), background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.manage-shell input:focus,
|
||||
.manage-shell select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
|
||||
}
|
||||
.manage-shell input:disabled,
|
||||
.manage-shell select:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.manage-shell .check { display: flex; flex-direction: row; align-items: center; gap: 0.45rem; font-weight: 600; color: var(--color-text-secondary); }
|
||||
.manage-shell .check input { accent-color: var(--color-brand); width: 1rem; height: 1rem; }
|
||||
.manage-shell .inline-label { flex-direction: row; align-items: center; gap: 0.5rem; }
|
||||
.manage-shell .inline { width: 6.5rem; padding: 0.4rem 0.5rem; }
|
||||
.manage-shell .ovr { width: 5.5rem; padding: 0.4rem 0.5rem; }
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────── */
|
||||
.manage-shell .primary,
|
||||
.manage-shell .secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.3rem;
|
||||
border-radius: var(--radius-control);
|
||||
padding: 0.5rem 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 150ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.manage-shell .primary { background: var(--color-brand); border-color: var(--color-brand); color: var(--color-on-brand); }
|
||||
.manage-shell .primary:hover:not(:disabled) { background: var(--color-brand-hover); border-color: var(--color-brand-hover); }
|
||||
.manage-shell .secondary { background: var(--color-bg-surface); border-color: var(--color-border); color: var(--color-text-primary); }
|
||||
.manage-shell .secondary:hover:not(:disabled) { background: var(--color-surface-hover); }
|
||||
.manage-shell .primary:disabled,
|
||||
.manage-shell .secondary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.manage-shell .link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-brand);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
}
|
||||
.manage-shell .link:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Order / customer detail ────────────────────────────────── */
|
||||
.manage-shell .lines td { font-size: 0.82rem; }
|
||||
.manage-shell .detail-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.65rem 0.05rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 0.5rem 0 0.85rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.manage-shell .detail-total strong { font-size: 1.05rem; color: var(--color-text-primary); }
|
||||
.manage-shell .actions { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; }
|
||||
.manage-shell .history { margin-top: 0.95rem; font-size: 0.8rem; color: var(--color-text-secondary); }
|
||||
.manage-shell .history summary { cursor: pointer; font-weight: 600; color: var(--color-text-secondary); }
|
||||
.manage-shell .history summary:hover { color: var(--color-text-primary); }
|
||||
.manage-shell .history ul { margin: 0.5rem 0 0; padding-left: 1.1rem; display: grid; gap: 0.3rem; }
|
||||
|
||||
.manage-shell .mini { list-style: none; margin: 0.3rem 0 0.7rem; padding: 0; display: grid; gap: 0.4rem; font-size: 0.82rem; }
|
||||
.manage-shell .mini li {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.manage-shell .visibility li { justify-content: flex-start; }
|
||||
|
||||
/* ── Card header: title on the left, primary action on the right ── */
|
||||
.manage-shell .card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.manage-shell .card-head h2 { margin: 0; }
|
||||
|
||||
/* ── Modal ──────────────────────────────────────────────────── */
|
||||
.manage-shell .modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
padding: 10vh 1rem 1rem;
|
||||
background: color-mix(in srgb, var(--color-text-primary) 32%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.manage-shell .modal {
|
||||
width: min(30rem, 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--color-bg-surface);
|
||||
padding: var(--space-card);
|
||||
}
|
||||
.manage-shell .modal.wide { width: min(40rem, 100%); }
|
||||
.manage-shell .modal h2 { margin: 0 0 1rem; }
|
||||
.manage-shell .modal .actions { justify-content: flex-end; margin-top: 1.15rem; }
|
||||
|
||||
/* ── Console primitives: shared master/detail vocabulary ─────
|
||||
* Reusable across the management console (products, orders, …) so each page
|
||||
* reads the same. Everything resolves from design tokens. */
|
||||
|
||||
/* Two-pane master/detail: a list on the left, a sticky detail on the right. */
|
||||
.manage-shell .console-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.7fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.manage-shell .console-split > .detail {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
/* Count chip beside a heading. */
|
||||
.manage-shell .count {
|
||||
margin-left: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Full-width search field above a list. */
|
||||
.manage-shell .list-search {
|
||||
width: 100%;
|
||||
margin-bottom: 0.7rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1), background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.manage-shell .list-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Inline reveal panel for create forms (replaces create modals). */
|
||||
.manage-shell .create-panel {
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.95rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.manage-shell .create-panel .actions { justify-content: flex-end; margin-top: 0.85rem; }
|
||||
|
||||
/* Two-line identity cell: bold name over a muted sub-line. */
|
||||
.manage-shell .id-cell { display: grid; gap: 0.12rem; min-width: 0; }
|
||||
.manage-shell .id-name { font-size: 0.88rem; font-weight: 600; color: var(--color-text-primary); }
|
||||
.manage-shell .id-sub { font-size: 0.74rem; color: var(--color-text-muted); }
|
||||
|
||||
/* Detail header: title block left, status + actions right. */
|
||||
.manage-shell .detail-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
padding-bottom: 0.95rem;
|
||||
margin-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
.manage-shell .detail-title h2 { margin: 0; font-size: 1.2rem; }
|
||||
.manage-shell .eyebrow {
|
||||
margin: 0 0 0.15rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.manage-shell .detail-head-actions { display: inline-flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
|
||||
|
||||
/* Square icon button (close, etc.). */
|
||||
.manage-shell .icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.3rem;
|
||||
height: 2.3rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
color 150ms cubic-bezier(0.22, 1, 0.36, 1), border-color 150ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.manage-shell .icon-btn:hover { background: var(--color-surface-hover); color: var(--color-text-primary); }
|
||||
.manage-shell .icon-btn svg { display: block; }
|
||||
|
||||
/* Section sub-header inside a detail panel. */
|
||||
.manage-shell .section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
.manage-shell .section-head h3 { margin: 0; }
|
||||
|
||||
/* Inline meta chips (PO, fulfilment, dates). */
|
||||
.manage-shell .meta { display: flex; flex-wrap: wrap; gap: 0.45rem 1.1rem; margin: 0.2rem 0 1rem; }
|
||||
.manage-shell .meta-item { display: grid; gap: 0.1rem; }
|
||||
.manage-shell .meta-item span {
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.manage-shell .meta-item strong { font-size: 0.84rem; font-weight: 600; color: var(--color-text-primary); }
|
||||
|
||||
/* Centered empty state for an unselected detail pane. */
|
||||
.manage-shell .empty-detail {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
.manage-shell .empty-detail h2 { margin: 0; }
|
||||
.manage-shell .empty-detail .muted { max-width: 28rem; margin: 0; }
|
||||
.manage-shell .empty-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
border-radius: 0.95rem;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
/* Status pill that reads as Active / Disabled from a boolean. */
|
||||
.manage-shell .pill.muted-pill {
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--panel-soft) 70%, var(--color-bg-surface));
|
||||
}
|
||||
|
||||
/* ── Workspace shell: a pinned header + tabs over a scrolling body ───
|
||||
* Gives a detail pane the feel of an anchored management surface. The head
|
||||
* (title, status, tabs, primary actions) stays put while the body scrolls,
|
||||
* capped to the viewport so the list beside it is always reachable. */
|
||||
.manage-shell .workspace {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
.manage-shell .workspace-head {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-card) var(--space-card) 0;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
.manage-shell .workspace-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--space-card);
|
||||
}
|
||||
|
||||
.manage-shell .workspace-tabs {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
margin-top: 0.9rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.manage-shell .workspace-tabs::-webkit-scrollbar { display: none; }
|
||||
.manage-shell .workspace-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 140ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.manage-shell .workspace-tab:hover { color: var(--color-text-primary); }
|
||||
.manage-shell .workspace-tab[aria-selected='true'] {
|
||||
color: var(--color-brand);
|
||||
border-bottom-color: var(--color-brand);
|
||||
}
|
||||
.manage-shell .workspace-tab svg { display: block; }
|
||||
.manage-shell .workspace-tab .tab-count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.manage-shell .workspace-tab[aria-selected='true'] .tab-count { color: var(--color-brand); }
|
||||
|
||||
/* Vertical rhythm for stacked sections inside a workspace body. */
|
||||
.manage-shell .ws-section + .ws-section { margin-top: 1.4rem; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.manage-shell .split,
|
||||
.manage-shell .console-split,
|
||||
.manage-shell .form-grid { grid-template-columns: 1fr; }
|
||||
.manage-shell .console-split > .detail { position: static; }
|
||||
.manage-shell .workspace { max-height: none; }
|
||||
}
|
||||
@@ -46,18 +46,20 @@
|
||||
--color-text-secondary: oklch(0.45 0.008 240);
|
||||
--color-text-muted: oklch(0.6 0.01 240);
|
||||
|
||||
/* ── Sidebar: light monochrome rail with the current item shown as the
|
||||
selected pill. Shared across themes so navigation stays consistent. ── */
|
||||
--sidebar-bg: oklch(0.985 0.001 240);
|
||||
--sidebar-hover: oklch(0.952 0.003 240);
|
||||
--sidebar-active-bg: #3290d9;
|
||||
--sidebar-active-text: var(--color-on-brand);
|
||||
--sidebar-border: oklch(0.9 0.004 240);
|
||||
--sidebar-text: oklch(0.34 0.006 240);
|
||||
--sidebar-text-strong: oklch(0.16 0.004 240);
|
||||
--sidebar-text-muted: oklch(0.56 0.008 240);
|
||||
--sidebar-icon: oklch(0.42 0.006 240);
|
||||
--sidebar-logo-bg: oklch(0.98 0.003 240);
|
||||
/* ── Sidebar: deep-green rail matching the customer ordering portal
|
||||
(CustomerPortalShell). The current item shows as a white pill with
|
||||
green text. Held constant across light/dark themes so internal staff
|
||||
and customers see the same navigation styling. ── */
|
||||
--sidebar-bg: #1f3a2c;
|
||||
--sidebar-hover: rgba(255, 255, 255, 0.08);
|
||||
--sidebar-active-bg: #ffffff;
|
||||
--sidebar-active-text: #1f3a2c;
|
||||
--sidebar-border: rgba(255, 255, 255, 0.12);
|
||||
--sidebar-text: rgba(231, 239, 233, 0.85);
|
||||
--sidebar-text-strong: #ffffff;
|
||||
--sidebar-text-muted: rgba(231, 239, 233, 0.6);
|
||||
--sidebar-icon: rgba(231, 239, 233, 0.8);
|
||||
--sidebar-logo-bg: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* ── Semantic ───────────────────────────────────────────── */
|
||||
--color-success: oklch(0.66 0.16 162); /* emerald, cohesive with accent */
|
||||
@@ -128,19 +130,20 @@
|
||||
--color-border: oklch(0.32 0.006 240);
|
||||
--color-divider: oklch(0.28 0.005 240);
|
||||
|
||||
/* ── Sidebar: dark rail tuned to the content theme so it stops
|
||||
rendering as a bright light strip in dark mode. Active item keeps
|
||||
the blue pill from light mode. ── */
|
||||
/* ── Sidebar: in dark mode the rail drops the deep-green and joins the
|
||||
neutral dark surfaces, sitting a touch below the app canvas so it
|
||||
reads as a distinct rail. The active item becomes a green-tinted pill
|
||||
with bright brand text rather than the light-mode white chip. ── */
|
||||
--sidebar-bg: oklch(0.2 0.005 240);
|
||||
--sidebar-hover: oklch(0.27 0.006 240);
|
||||
--sidebar-active-bg: #3290d9;
|
||||
--sidebar-active-text: var(--color-on-brand);
|
||||
--sidebar-active-bg: color-mix(in srgb, var(--color-brand) 22%, var(--color-bg-surface));
|
||||
--sidebar-active-text: oklch(0.9 0.07 162);
|
||||
--sidebar-border: oklch(0.3 0.006 240);
|
||||
--sidebar-text: oklch(0.78 0.006 240);
|
||||
--sidebar-text-strong: oklch(0.96 0.003 240);
|
||||
--sidebar-text-muted: oklch(0.6 0.008 240);
|
||||
--sidebar-icon: oklch(0.68 0.008 240);
|
||||
--sidebar-logo-bg: oklch(0.26 0.006 240);
|
||||
--sidebar-icon: oklch(0.72 0.006 240);
|
||||
--sidebar-logo-bg: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* ── Text (neutral) ─────────────────────────────────────── */
|
||||
--color-text-primary: oklch(0.96 0.003 240);
|
||||
@@ -560,3 +563,49 @@ a {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Tooltip (rendered to <body> by the use:tooltip action)
|
||||
----------------------------------------------------------------------------
|
||||
Auto-inverts via the text/surface tokens: a near-black bubble in light mode,
|
||||
a near-white bubble in dark mode — readable contrast in both.
|
||||
============================================================================ */
|
||||
|
||||
.app-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
padding: 0.36rem 0.55rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-text-primary);
|
||||
color: var(--color-bg-surface);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.005em;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
text-align: center;
|
||||
width: max-content;
|
||||
max-width: min(16rem, calc(100vw - 1rem));
|
||||
pointer-events: none;
|
||||
box-shadow: 0 6px 20px -6px rgba(0, 0, 0, 0.35), 0 2px 6px -2px rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity 130ms ease, transform 130ms ease;
|
||||
}
|
||||
|
||||
.app-tooltip[data-placement='top'] {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.app-tooltip.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-tooltip {
|
||||
transition: opacity 130ms ease;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-svelte';
|
||||
import type { TableController } from './table.svelte';
|
||||
|
||||
let {
|
||||
label,
|
||||
column,
|
||||
controller
|
||||
}: {
|
||||
label: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
controller: TableController<any>;
|
||||
column: string;
|
||||
} = $props();
|
||||
|
||||
const active = $derived(controller.sortKey === column);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="sort-header"
|
||||
class:active
|
||||
aria-label={`Sort by ${label}${active ? (controller.sortDir === 'asc' ? ', ascending' : ', descending') : ''}`}
|
||||
onclick={() => controller.toggleSort(column)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{#if active}
|
||||
{#if controller.sortDir === 'asc'}
|
||||
<ChevronUp size={13} strokeWidth={2.6} />
|
||||
{:else}
|
||||
<ChevronDown size={13} strokeWidth={2.6} />
|
||||
{/if}
|
||||
{:else}
|
||||
<ChevronsUpDown size={13} strokeWidth={2} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.sort-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
width: 100%;
|
||||
min-height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
/* Inherit the .log-head typography (size/weight/transform/letter-spacing). */
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-header :global(svg) {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.sort-header:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sort-header:hover :global(svg) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sort-header.active {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.sort-header.active :global(svg) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sort-header:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
// Shared client-side table controller: sorting + pagination over an already
|
||||
// filtered row set. Both the Mix Editor and Ingredients Editor feed their
|
||||
// filtered `visibleRows` in and render `rows` out, so the two surfaces stay
|
||||
// behaviourally identical. Keeping the page math here (rather than ad-hoc
|
||||
// `$effect`s per page) means navigation can't get clamped back to page 1.
|
||||
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
export type SortValue = string | number | boolean | null | undefined;
|
||||
export type Accessor<T> = (row: T) => SortValue;
|
||||
|
||||
const PAGE_SIZES = [10, 25, 50, 100] as const;
|
||||
|
||||
function isEmpty(value: SortValue): boolean {
|
||||
return value === null || value === undefined || value === '';
|
||||
}
|
||||
|
||||
function compareNonEmpty(a: SortValue, b: SortValue): number {
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
||||
return a === b ? 0 : a ? -1 : 1;
|
||||
}
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export class TableController<T> {
|
||||
readonly pageSizes = PAGE_SIZES;
|
||||
|
||||
page = $state(1);
|
||||
pageSize = $state(25);
|
||||
sortKey = $state<string | null>(null);
|
||||
sortDir = $state<SortDir>('asc');
|
||||
|
||||
private readonly source: () => readonly T[];
|
||||
private readonly accessors: Record<string, Accessor<T>>;
|
||||
|
||||
constructor(source: () => readonly T[], accessors: Record<string, Accessor<T>> = {}) {
|
||||
this.source = source;
|
||||
this.accessors = accessors;
|
||||
}
|
||||
|
||||
readonly sorted = $derived.by<readonly T[]>(() => {
|
||||
const rows = this.source();
|
||||
const accessor = this.sortKey ? this.accessors[this.sortKey] : undefined;
|
||||
if (!accessor) return rows;
|
||||
const dir = this.sortDir === 'asc' ? 1 : -1;
|
||||
// Empties always sort last, regardless of direction.
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = accessor(a);
|
||||
const bv = accessor(b);
|
||||
const aEmpty = isEmpty(av);
|
||||
const bEmpty = isEmpty(bv);
|
||||
if (aEmpty || bEmpty) {
|
||||
if (aEmpty && bEmpty) return 0;
|
||||
return aEmpty ? 1 : -1;
|
||||
}
|
||||
return compareNonEmpty(av, bv) * dir;
|
||||
});
|
||||
});
|
||||
|
||||
readonly total = $derived(this.sorted.length);
|
||||
readonly totalPages = $derived(Math.max(1, Math.ceil(this.total / this.pageSize)));
|
||||
// The page we actually show. Derived clamping means shrinking the result set
|
||||
// (via filters or a larger page size) can never strand the view on an empty page.
|
||||
readonly currentPage = $derived(Math.min(Math.max(1, this.page), this.totalPages));
|
||||
readonly pageStart = $derived(this.total === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1);
|
||||
readonly pageEnd = $derived(Math.min(this.total, this.currentPage * this.pageSize));
|
||||
readonly rows = $derived(this.sorted.slice(this.pageStart === 0 ? 0 : this.pageStart - 1, this.pageEnd));
|
||||
readonly canPrev = $derived(this.currentPage > 1);
|
||||
readonly canNext = $derived(this.currentPage < this.totalPages);
|
||||
|
||||
toggleSort(key: string): void {
|
||||
if (!this.accessors[key]) return;
|
||||
if (this.sortKey === key) {
|
||||
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortKey = key;
|
||||
this.sortDir = 'asc';
|
||||
}
|
||||
this.page = 1;
|
||||
}
|
||||
|
||||
setPage(next: number): void {
|
||||
this.page = Math.min(Math.max(1, next), this.totalPages);
|
||||
}
|
||||
|
||||
next(): void {
|
||||
this.setPage(this.currentPage + 1);
|
||||
}
|
||||
|
||||
prev(): void {
|
||||
this.setPage(this.currentPage - 1);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.page = 1;
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ export type MixCalculatorLine = {
|
||||
required_kg: number;
|
||||
mix_percentage: number;
|
||||
unit: string;
|
||||
rounding_decimals?: number;
|
||||
category?: string | null;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
@@ -302,10 +304,74 @@ export type EditorProductUpdateInput = {
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type EditorMixCreateInput = {
|
||||
client_name: string;
|
||||
name: string;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type EditorMixUpdateInput = {
|
||||
client_name?: string;
|
||||
name?: string;
|
||||
notes?: string | null;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
export type EditorMixRow = {
|
||||
id: number;
|
||||
tenant_id: string;
|
||||
client_name: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
product_count: number;
|
||||
visible_product_count: number;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type EditorMixIngredient = {
|
||||
id: number;
|
||||
raw_material_id: number;
|
||||
raw_material_name: string;
|
||||
quantity_kg: number;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type EditorMixFormula = {
|
||||
id: number;
|
||||
tenant_id: string;
|
||||
client_name: string;
|
||||
name: string;
|
||||
ingredients: EditorMixIngredient[];
|
||||
total_kg: number;
|
||||
};
|
||||
|
||||
export type EditorResolvedMixIngredient = {
|
||||
raw_material_id: number;
|
||||
raw_material_name: string;
|
||||
quantity_kg: number;
|
||||
mix_percentage: number;
|
||||
unit: string;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
// A mix formula resolved exactly as the Mix Calculator reads it. `source` is
|
||||
// 'product' when it comes from a representative product's own formula, or 'mix'
|
||||
// when it comes from the shared mix master fallback.
|
||||
export type EditorResolvedMixFormula = {
|
||||
id: number;
|
||||
tenant_id: string;
|
||||
client_name: string;
|
||||
name: string;
|
||||
source: 'product' | 'mix';
|
||||
product_id: number | null;
|
||||
ingredients: EditorResolvedMixIngredient[];
|
||||
total_kg: number;
|
||||
};
|
||||
|
||||
export type EditorMixFormulaRowInput = {
|
||||
raw_material_id: number;
|
||||
quantity_kg: number;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type EditorProductIngredient = {
|
||||
@@ -328,6 +394,54 @@ export type EditorProductFormula = {
|
||||
total_kg: number;
|
||||
};
|
||||
|
||||
export type EditorIngredientRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
supplier: string | null;
|
||||
unit_of_measure: string;
|
||||
kg_per_unit: number;
|
||||
status: string;
|
||||
rounding_decimals: number;
|
||||
category: string | null;
|
||||
notes: string | null;
|
||||
cost_per_kg: number | null;
|
||||
usage_count: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type EditorIngredientCreateInput = {
|
||||
name: string;
|
||||
supplier?: string | null;
|
||||
unit_of_measure: string;
|
||||
kg_per_unit: number;
|
||||
status?: string;
|
||||
rounding_decimals?: number;
|
||||
category?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type EditorIngredientUpdateInput = Partial<EditorIngredientCreateInput>;
|
||||
|
||||
export type EditorChangeFieldDelta = {
|
||||
field: string;
|
||||
label: string;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
};
|
||||
|
||||
export type EditorChangeEvent = {
|
||||
id: number;
|
||||
entity_type: 'mix' | 'ingredient';
|
||||
entity_id: number;
|
||||
action: string;
|
||||
actor_name: string;
|
||||
actor_email: string;
|
||||
actor_role: string | null;
|
||||
summary: string;
|
||||
changes: EditorChangeFieldDelta[];
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Scenario = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -501,6 +615,69 @@ export type LoginResponse = {
|
||||
role_name?: string | null;
|
||||
};
|
||||
|
||||
// Internal Hunter Stock Feeds user (the access-control system), as returned by
|
||||
// /api/access/users. Distinct from the B2B ordering ClientUser accounts.
|
||||
export type InternalUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
role: string | null;
|
||||
role_id: number | null;
|
||||
// Lean owner accounts: editable but never deletable.
|
||||
is_protected: boolean;
|
||||
};
|
||||
|
||||
export type InternalRoleOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
export type InternalRoleModuleDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
levels: string[];
|
||||
};
|
||||
|
||||
export type InternalRole = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissions: string[];
|
||||
module_permissions: Record<string, string>;
|
||||
is_protected: boolean;
|
||||
user_count: number;
|
||||
};
|
||||
|
||||
export type InternalRoleCreateInput = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
module_permissions: Record<string, string>;
|
||||
};
|
||||
|
||||
export type InternalRoleUpdateInput = {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
module_permissions?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type InternalUserCreateInput = {
|
||||
email: string;
|
||||
name: string;
|
||||
role_id?: number | null;
|
||||
is_active?: boolean;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type InternalUserUpdateInput = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
role_id?: number | null;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type RawMaterialCreateInput = {
|
||||
name: string;
|
||||
supplier?: string | null;
|
||||
@@ -616,6 +793,19 @@ export type ThroughputEntryCreateInput = {
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export type ThroughputEntryUpdateInput = Partial<ThroughputEntryCreateInput>;
|
||||
|
||||
export type ThroughputImportResult = {
|
||||
entries_imported: number;
|
||||
entries_skipped: number;
|
||||
products_created: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export type ThroughputDeleteAllResult = {
|
||||
entries_deleted: number;
|
||||
};
|
||||
|
||||
export type ThroughputEntryListParams = {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
@@ -745,6 +935,8 @@ export type OrderingCustomer = {
|
||||
user_count: number;
|
||||
price_list_id: number | null;
|
||||
discount_percent: number;
|
||||
xero_contact_id?: string | null;
|
||||
xero_contact_name?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -793,6 +985,7 @@ export type OrderingNotificationSettings = {
|
||||
|
||||
export type XeroStatus = {
|
||||
connection: { configured: boolean; mode: string; base_url: string; checked_at: string; missing_env: string[] };
|
||||
contact_links: { linked: number; total: number; unlinked: number };
|
||||
recent_syncs: {
|
||||
id: number;
|
||||
order_id: number;
|
||||
@@ -802,3 +995,27 @@ export type XeroStatus = {
|
||||
created_at: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type XeroContact = {
|
||||
contact_id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type XeroContactList = {
|
||||
contacts: XeroContact[];
|
||||
stubbed: boolean;
|
||||
};
|
||||
|
||||
export type XeroContactLinkRow = {
|
||||
customer_id: number;
|
||||
customer_name: string;
|
||||
client_code: string;
|
||||
linked: boolean;
|
||||
xero_contact_id: string | null;
|
||||
xero_contact_name: string | null;
|
||||
xero_contact_email: string | null;
|
||||
last_synced_at: string | null;
|
||||
suggested_contact_id: string | null;
|
||||
};
|
||||
|
||||
@@ -264,6 +264,7 @@ export function canAccessRoute(session: AppSession | null | undefined, pathname:
|
||||
if (pathname.startsWith('/product-costing')) return canOpenProductCosting(session);
|
||||
if (pathname.startsWith('/products')) return canOpenProducts(session);
|
||||
if (pathname.startsWith('/editor')) return canOpenEditor(session);
|
||||
if (pathname.startsWith('/ingredients')) return canOpenEditor(session);
|
||||
if (pathname.startsWith('/scenarios')) return canOpenScenarios(session);
|
||||
if (pathname.startsWith('/reporting')) return canOpenReporting(session);
|
||||
if (pathname.startsWith('/settings')) return canOpenSettings(session);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import '$lib/theme';
|
||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ClientShell from '$lib/components/ClientShell.svelte';
|
||||
import AppShell from '$lib/components/AppShell.svelte';
|
||||
import CustomerPortalShell from '$lib/components/CustomerPortalShell.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import { clientSession } from '$lib/session';
|
||||
@@ -50,9 +50,9 @@
|
||||
{@render children()}
|
||||
</CustomerPortalShell>
|
||||
{:else}
|
||||
<ClientShell>
|
||||
<AppShell>
|
||||
{@render children()}
|
||||
</ClientShell>
|
||||
</AppShell>
|
||||
{/if}
|
||||
|
||||
<Toast />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { api } from '$lib/api';
|
||||
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||
import { canOpenDashboard, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||
import { canOpenDashboard, getWorkspaceHomeHref, isCustomerPortalSession } from '$lib/workspace-access';
|
||||
import type { DashboardSummary } from '$lib/types';
|
||||
|
||||
const EMPTY_SUMMARY: DashboardSummary = {
|
||||
@@ -22,6 +22,14 @@ export function load({ fetch }) {
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
|
||||
// B2B ordering customers must never land on (or flash) the internal
|
||||
// dashboard — even if their account happens to carry the dashboard module.
|
||||
// Send them straight to their catalogue before any dashboard data loads.
|
||||
if (isCustomerPortalSession(session)) {
|
||||
throw redirect(307, '/ordering');
|
||||
}
|
||||
|
||||
if (!canOpenDashboard(session)) {
|
||||
throw redirect(307, getWorkspaceHomeHref(session));
|
||||
}
|
||||
|
||||
@@ -437,7 +437,6 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p,
|
||||
@@ -453,21 +452,12 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-intro,
|
||||
.metric-row,
|
||||
.workspace-grid,
|
||||
.preview-grid {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-intro h2 {
|
||||
margin: 0.35rem 0 0.45rem;
|
||||
max-width: 18ch;
|
||||
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-intro p:last-child,
|
||||
.metric-card p,
|
||||
.card-toolbar p,
|
||||
.client-row span,
|
||||
|
||||
+1028
-285
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ export async function load({ fetch }) {
|
||||
|
||||
try {
|
||||
const [rows, rawMaterials] = await Promise.all([
|
||||
api.editorProducts({ limit: 1000 }, fetch),
|
||||
api.editorMixes({ limit: 1000 }, fetch),
|
||||
api.rawMaterials(fetch)
|
||||
]);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { api } from '$lib/api';
|
||||
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||
import { canOpenEditor, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { ingredients: [] };
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
if (!canOpenEditor(session)) {
|
||||
throw redirect(307, getWorkspaceHomeHref(session));
|
||||
}
|
||||
|
||||
try {
|
||||
const ingredients = await api.editorIngredients(fetch);
|
||||
return { ingredients };
|
||||
} catch {
|
||||
return { ingredients: [] };
|
||||
}
|
||||
}
|
||||
@@ -180,14 +180,6 @@
|
||||
</script>
|
||||
|
||||
<div class="ordering">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Ordering Portal</p>
|
||||
<h1>Order catalogue</h1>
|
||||
<p class="sub">Your account-specific products and pricing. Prices exclude GST.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<!-- Catalogue -->
|
||||
<section class="catalogue surface-card">
|
||||
@@ -334,12 +326,9 @@
|
||||
|
||||
<style>
|
||||
.ordering { display: grid; gap: 1.25rem; }
|
||||
h1 { margin: 0.2rem 0; font-size: 1.5rem; }
|
||||
h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||
h3 { margin: 0; font-size: 0.98rem; }
|
||||
p { margin: 0; }
|
||||
.eyebrow { color: var(--color-brand, #2f6f4f); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.sub { color: #64776b; font-size: 0.88rem; }
|
||||
.surface-card { border: 1px solid rgba(34, 54, 45, 0.12); border-radius: 1rem; background: var(--surface, rgba(255,255,255,0.9)); padding: 1.1rem; }
|
||||
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 22rem; gap: 1.25rem; align-items: start; }
|
||||
.toolbar { display: grid; gap: 0.6rem; margin-bottom: 1rem; }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import '$lib/ordering/manage.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<!-- Section navigation lives in the primary left rail (and the mobile drawer),
|
||||
so the console pages don't repeat it inline. -->
|
||||
<div class="manage-shell">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.manage-shell { display: grid; gap: 1rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||
|
||||
// Single access guard for the whole Order Management family. Each child route
|
||||
// (orders, products, customers, pricing, settings, integrations) loads its own
|
||||
// data; this layout just keeps non-managers out of all of them in one place.
|
||||
export async function load() {
|
||||
if (!hasStoredClientSession()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
if (!canManageOrdering(session)) {
|
||||
throw redirect(307, getWorkspaceHomeHref(session));
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -1,59 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { ClipboardList, X } from 'lucide-svelte';
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import type {
|
||||
CatalogueProduct,
|
||||
CustomerPricing,
|
||||
CustomerVisibilityRow,
|
||||
Order,
|
||||
OrderingCustomer,
|
||||
OrderingCustomerUser,
|
||||
OrderingNotificationSettings,
|
||||
XeroStatus
|
||||
} from '$lib/types';
|
||||
import { tooltip } from '$lib/actions/tooltip';
|
||||
import { money, label, statusTone, ORDER_STATUSES } from '$lib/ordering/format';
|
||||
import type { Order } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
type Tab = 'orders' | 'products' | 'customers' | 'settings';
|
||||
let tab = $state<Tab>('orders');
|
||||
|
||||
// Mutable local copies of the loader data (refresh helpers reassign these).
|
||||
// Seeded from `data` via an effect so navigation re-syncs without the
|
||||
// "only captures the initial value" warning.
|
||||
let orders = $state<Order[]>([]);
|
||||
let products = $state<CatalogueProduct[]>([]);
|
||||
let customers = $state<OrderingCustomer[]>([]);
|
||||
let xero = $state<XeroStatus | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
orders = data.orders ?? [];
|
||||
products = data.products ?? [];
|
||||
customers = data.customers ?? [];
|
||||
xero = data.xero ?? null;
|
||||
});
|
||||
|
||||
const STATUSES = [
|
||||
'submitted',
|
||||
'under_review',
|
||||
'confirmed',
|
||||
'sent_to_xero',
|
||||
'in_production',
|
||||
'ready_for_pickup',
|
||||
'dispatched',
|
||||
'completed',
|
||||
'cancelled'
|
||||
];
|
||||
const CATEGORIES = ['grains', 'premixed', 'bags', 'bulk_loads', 'custom_blends', 'services'];
|
||||
let listQuery = $state('');
|
||||
const filteredOrders = $derived.by(() => {
|
||||
const q = listQuery.trim().toLowerCase();
|
||||
if (!q) return orders;
|
||||
return orders.filter((o) => {
|
||||
const ref = (o.order_number ?? `#${o.id}`).toLowerCase();
|
||||
return ref.includes(q) || (o.customer_name ?? '').toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
function money(v: number | null | undefined) {
|
||||
if (v == null) return '—';
|
||||
return new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(v);
|
||||
}
|
||||
function label(s: string) {
|
||||
return s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// --- Orders ---------------------------------------------------------------
|
||||
let selectedOrder = $state<Order | null>(null);
|
||||
let statusChoice = $state('');
|
||||
|
||||
@@ -65,6 +35,13 @@
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load order.');
|
||||
}
|
||||
}
|
||||
function closeOrder() {
|
||||
selectedOrder = null;
|
||||
statusChoice = '';
|
||||
}
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && selectedOrder) closeOrder();
|
||||
}
|
||||
async function refreshOrders() {
|
||||
try {
|
||||
orders = await api.orderingAdmin.orders();
|
||||
@@ -84,7 +61,10 @@
|
||||
async function overrideLine(lineId: number, value: string) {
|
||||
if (!selectedOrder || value === '') return;
|
||||
try {
|
||||
selectedOrder = await api.orderingAdmin.overrideLine(selectedOrder.id, lineId, { unit_price: Number(value), reason: 'Admin override' });
|
||||
selectedOrder = await api.orderingAdmin.overrideLine(selectedOrder.id, lineId, {
|
||||
unit_price: Number(value),
|
||||
reason: 'Admin override'
|
||||
});
|
||||
toast.success('Line price overridden.');
|
||||
await refreshOrders();
|
||||
} catch (e) {
|
||||
@@ -112,480 +92,169 @@
|
||||
toast.error(e instanceof Error ? e.message : 'Could not reopen.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Products -------------------------------------------------------------
|
||||
let newProduct = $state<Record<string, any>>({ name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true });
|
||||
|
||||
async function refreshProducts() {
|
||||
try {
|
||||
products = await api.orderingAdmin.products();
|
||||
} catch {}
|
||||
}
|
||||
async function createProduct() {
|
||||
if (!newProduct.name || !newProduct.sku) return toast.error('Name and SKU are required.');
|
||||
try {
|
||||
await api.orderingAdmin.createProduct({ ...newProduct, base_price: newProduct.base_price === null || newProduct.base_price === '' ? null : Number(newProduct.base_price) });
|
||||
toast.success('Product created.');
|
||||
newProduct = { name: '', sku: '', category: 'grains', unit_of_measure: '20kg bag', min_order_quantity: 1, base_price: null, requires_quote: false, active: true };
|
||||
await refreshProducts();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not create product.');
|
||||
}
|
||||
}
|
||||
async function toggleProductActive(p: CatalogueProduct) {
|
||||
try {
|
||||
await api.orderingAdmin.updateProduct(p.id, { active: !p.active });
|
||||
await refreshProducts();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function saveProductPrice(p: CatalogueProduct, value: string) {
|
||||
try {
|
||||
await api.orderingAdmin.updateProduct(p.id, { base_price: value === '' ? null : Number(value) });
|
||||
toast.success('Base price updated.');
|
||||
await refreshProducts();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Customers ------------------------------------------------------------
|
||||
let newCustomer = $state({ name: '', client_code: '' });
|
||||
let selectedCustomer = $state<OrderingCustomer | null>(null);
|
||||
let custUsers = $state<OrderingCustomerUser[]>([]);
|
||||
let custPricing = $state<CustomerPricing | null>(null);
|
||||
let custVisibility = $state<CustomerVisibilityRow[]>([]);
|
||||
let newUser = $state({ full_name: '', email: '', role: 'buyer' });
|
||||
let discountInput = $state(0);
|
||||
let newPrice = $state<Record<string, any>>({ product_id: '', unit_price: '', rule_type: 'fixed' });
|
||||
|
||||
async function refreshCustomers() {
|
||||
try {
|
||||
customers = await api.orderingAdmin.customers();
|
||||
} catch {}
|
||||
}
|
||||
async function createCustomer() {
|
||||
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomer(newCustomer);
|
||||
toast.success('Customer created.');
|
||||
newCustomer = { name: '', client_code: '' };
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
|
||||
}
|
||||
}
|
||||
async function openCustomer(c: OrderingCustomer) {
|
||||
selectedCustomer = c;
|
||||
discountInput = c.discount_percent;
|
||||
try {
|
||||
[custUsers, custPricing, custVisibility] = await Promise.all([
|
||||
api.orderingAdmin.customerUsers(c.id),
|
||||
api.orderingAdmin.pricing(c.id),
|
||||
api.orderingAdmin.visibility(c.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load customer.');
|
||||
}
|
||||
}
|
||||
async function toggleCustomerStatus(c: OrderingCustomer) {
|
||||
try {
|
||||
const updated = await api.orderingAdmin.updateCustomer(c.id, { status: c.status === 'active' ? 'disabled' : 'active' });
|
||||
toast.success(`Customer ${updated.status}.`);
|
||||
await refreshCustomers();
|
||||
if (selectedCustomer?.id === c.id) selectedCustomer = updated;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function addUser() {
|
||||
if (!selectedCustomer) return;
|
||||
if (!newUser.full_name || !newUser.email) return toast.error('Name and email required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomerUser(selectedCustomer.id, newUser);
|
||||
toast.success('User invited.');
|
||||
newUser = { full_name: '', email: '', role: 'buyer' };
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not add user.');
|
||||
}
|
||||
}
|
||||
async function toggleUserStatus(u: OrderingCustomerUser) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
const next = u.status === 'suspended' ? 'active' : 'suspended';
|
||||
await api.orderingAdmin.updateCustomerUser(selectedCustomer.id, u.id, { status: next });
|
||||
custUsers = await api.orderingAdmin.customerUsers(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
async function saveDiscount() {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
custPricing = await api.orderingAdmin.setAssignment(selectedCustomer.id, { price_list_id: custPricing?.price_list_id ?? null, discount_percent: Number(discountInput) });
|
||||
toast.success('Discount saved.');
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save discount.');
|
||||
}
|
||||
}
|
||||
async function addProductPrice() {
|
||||
if (!selectedCustomer || !newPrice.product_id) return toast.error('Choose a product.');
|
||||
try {
|
||||
custPricing = await api.orderingAdmin.setProductPrice(selectedCustomer.id, {
|
||||
product_id: Number(newPrice.product_id),
|
||||
unit_price: newPrice.rule_type === 'quote' || newPrice.unit_price === '' ? null : Number(newPrice.unit_price),
|
||||
rule_type: newPrice.rule_type
|
||||
});
|
||||
toast.success('Customer price saved.');
|
||||
newPrice = { product_id: '', unit_price: '', rule_type: 'fixed' };
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save price.');
|
||||
}
|
||||
}
|
||||
async function removeProductPrice(productId: number) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
await api.orderingAdmin.deleteProductPrice(selectedCustomer.id, productId);
|
||||
custPricing = await api.orderingAdmin.pricing(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not remove price.');
|
||||
}
|
||||
}
|
||||
async function toggleVisibility(row: CustomerVisibilityRow) {
|
||||
if (!selectedCustomer) return;
|
||||
try {
|
||||
await api.orderingAdmin.setVisibility(selectedCustomer.id, { product_id: row.product_id, visible: !row.visible });
|
||||
custVisibility = await api.orderingAdmin.visibility(selectedCustomer.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Update failed.');
|
||||
}
|
||||
}
|
||||
function productName(id: number) {
|
||||
return products.find((p) => p.id === id)?.name ?? `#${id}`;
|
||||
}
|
||||
|
||||
// --- Settings -------------------------------------------------------------
|
||||
let settings = $state<OrderingNotificationSettings | null>(null);
|
||||
async function loadSettings() {
|
||||
try {
|
||||
settings = await api.orderingAdmin.notificationSettings();
|
||||
xero = await api.orderingAdmin.xeroStatus();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load settings.');
|
||||
}
|
||||
}
|
||||
async function saveSettings() {
|
||||
if (!settings) return;
|
||||
try {
|
||||
settings = await api.orderingAdmin.updateNotificationSettings(settings);
|
||||
toast.success('Settings saved.');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not save settings.');
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (tab === 'settings' && !settings) loadSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="admin-ordering">
|
||||
<header>
|
||||
<p class="eyebrow">Ordering</p>
|
||||
<h1>Order management</h1>
|
||||
</header>
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
<nav class="tabs">
|
||||
<button class:active={tab === 'orders'} onclick={() => (tab = 'orders')}>Orders</button>
|
||||
<button class:active={tab === 'products'} onclick={() => (tab = 'products')}>Products</button>
|
||||
<button class:active={tab === 'customers'} onclick={() => (tab = 'customers')}>Customers & Pricing</button>
|
||||
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings & Xero</button>
|
||||
</nav>
|
||||
<div class="console-split">
|
||||
<!-- ── Left: order queue ─────────────────────────────────────────────────── -->
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Orders <span class="count">{filteredOrders.length}</span></h2>
|
||||
</div>
|
||||
|
||||
{#if tab === 'orders'}
|
||||
<div class="split">
|
||||
<section class="surface-card">
|
||||
<h2>Order queue</h2>
|
||||
{#if !orders.length}
|
||||
<p class="empty">No submitted orders.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Subtotal</th><th>Xero</th></tr></thead>
|
||||
<tbody>
|
||||
{#each orders as o (o.id)}
|
||||
<tr class:selected={selectedOrder?.id === o.id} onclick={() => openOrder(o)}>
|
||||
<td>{o.order_number ?? `#${o.id}`}</td>
|
||||
<td>{o.customer_name}</td>
|
||||
<td><span class="pill">{label(o.status)}</span></td>
|
||||
<td>{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
|
||||
<td>{o.xero_status ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
<input class="list-search" type="search" placeholder="Search order or customer" bind:value={listQuery} />
|
||||
|
||||
{#if selectedOrder}
|
||||
<section class="surface-card detail">
|
||||
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
|
||||
<p class="muted">{selectedOrder.customer_name} · {label(selectedOrder.status)} · PO {selectedOrder.purchase_order_number ?? '—'}</p>
|
||||
<table class="lines">
|
||||
<thead><tr><th>Product</th><th>Qty</th><th>Unit</th><th>Override</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{#each selectedOrder.lines as l (l.id)}
|
||||
<tr>
|
||||
<td>{l.product_name}</td>
|
||||
<td>{l.quantity}</td>
|
||||
<td>{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
|
||||
<td>
|
||||
<input class="ovr" type="number" step="0.01" placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
|
||||
onchange={(e) => overrideLine(l.id, e.currentTarget.value)} />
|
||||
</td>
|
||||
<td>{money(l.line_total)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
|
||||
|
||||
<div class="actions">
|
||||
<select bind:value={statusChoice}>
|
||||
<option value="">Change status…</option>
|
||||
{#each STATUSES as s}<option value={s}>{label(s)}</option>{/each}
|
||||
</select>
|
||||
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
|
||||
<button class="secondary" onclick={sendToXero}>Send to Xero</button>
|
||||
<button class="secondary" onclick={reopenOrder}>Reopen</button>
|
||||
</div>
|
||||
|
||||
{#if selectedOrder.status_history?.length}
|
||||
<details class="history">
|
||||
<summary>Status history ({selectedOrder.status_history.length})</summary>
|
||||
<ul>
|
||||
{#each selectedOrder.status_history as h}
|
||||
<li>{label(h.from_status ?? 'new')} → {label(h.to_status)} · {h.actor_name ?? h.actor_type} · {new Date(h.created_at).toLocaleString('en-AU')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{#if filteredOrders.length}
|
||||
<table class="clickable">
|
||||
<thead>
|
||||
<tr><th>Order</th><th>Status</th><th class="amt">Subtotal</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredOrders as o (o.id)}
|
||||
<tr
|
||||
class:selected={selectedOrder?.id === o.id}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-pressed={selectedOrder?.id === o.id}
|
||||
onclick={() => openOrder(o)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openOrder(o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<div class="id-cell">
|
||||
<span class="id-name">{o.order_number ?? `#${o.id}`}</span>
|
||||
<span class="id-sub">{o.customer_name ?? 'Unknown customer'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="pill {statusTone(o.status)}">{label(o.status)}</span></td>
|
||||
<td class="amt">{o.requires_quote ? 'Quote' : money(o.subtotal_ex_gst)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if orders.length}
|
||||
<p class="empty">No orders match “{listQuery}”.</p>
|
||||
{:else}
|
||||
<p class="empty">No submitted orders yet.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if tab === 'products'}
|
||||
<section class="surface-card">
|
||||
<h2>New product</h2>
|
||||
<div class="form-grid">
|
||||
<label>Name<input bind:value={newProduct.name} /></label>
|
||||
<label>SKU<input bind:value={newProduct.sku} /></label>
|
||||
<label>Category
|
||||
<select bind:value={newProduct.category}>{#each CATEGORIES as c}<option value={c}>{label(c)}</option>{/each}</select>
|
||||
</label>
|
||||
<label>Unit of measure<input bind:value={newProduct.unit_of_measure} /></label>
|
||||
<label>Min order qty<input type="number" bind:value={newProduct.min_order_quantity} /></label>
|
||||
<label>Base price (ex GST)<input type="number" step="0.01" bind:value={newProduct.base_price} /></label>
|
||||
<label class="check"><input type="checkbox" bind:checked={newProduct.requires_quote} /> Requires quote</label>
|
||||
<button class="primary" onclick={createProduct}>Create product</button>
|
||||
<!-- ── Right: order detail ───────────────────────────────────────────────── -->
|
||||
{#if selectedOrder}
|
||||
<section class="surface-card detail workspace">
|
||||
<div class="workspace-head">
|
||||
<div class="detail-head head-row">
|
||||
<div class="detail-title">
|
||||
<p class="eyebrow">{selectedOrder.customer_name ?? 'Order'}</p>
|
||||
<h2>{selectedOrder.order_number ?? `Order #${selectedOrder.id}`}</h2>
|
||||
</div>
|
||||
<div class="detail-head-actions">
|
||||
<span class="pill {statusTone(selectedOrder.status)}">{label(selectedOrder.status)}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={closeOrder}
|
||||
aria-label="Close order"
|
||||
use:tooltip={{ label: 'Close (Esc)', placement: 'bottom' }}
|
||||
>
|
||||
<X size={17} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Catalogue ({products.length})</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>SKU</th><th>Category</th><th>Base price</th><th>Active</th><th></th></tr></thead>
|
||||
<div class="actions order-toolbar">
|
||||
<select bind:value={statusChoice} aria-label="Change status">
|
||||
<option value="">Change status…</option>
|
||||
{#each ORDER_STATUSES as s}<option value={s}>{label(s)}</option>{/each}
|
||||
</select>
|
||||
<button class="primary" onclick={applyStatus} disabled={!statusChoice}>Apply</button>
|
||||
<button class="secondary" onclick={sendToXero} use:tooltip={'Create or update the Xero invoice'}>Send to Xero</button>
|
||||
<button class="secondary" onclick={reopenOrder} use:tooltip={'Return this order to draft for editing'}>Reopen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace-body">
|
||||
<div class="meta">
|
||||
<div class="meta-item"><span>PO number</span><strong>{selectedOrder.purchase_order_number ?? '—'}</strong></div>
|
||||
<div class="meta-item"><span>Fulfilment</span><strong>{label(selectedOrder.fulfilment_method)}</strong></div>
|
||||
<div class="meta-item"><span>Xero</span><strong>{selectedOrder.xero_status ?? 'Not sent'}</strong></div>
|
||||
</div>
|
||||
|
||||
<table class="lines">
|
||||
<thead>
|
||||
<tr><th>Product</th><th class="amt">Qty</th><th class="amt">Unit</th><th>Override</th><th class="amt">Total</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each products as p (p.id)}
|
||||
{#each selectedOrder.lines as l (l.id)}
|
||||
<tr>
|
||||
<td>{p.name}{#if p.requires_quote}<span class="tag">quote</span>{/if}</td>
|
||||
<td>{p.sku}</td>
|
||||
<td>{label(p.category)}</td>
|
||||
<td><input class="inline" type="number" step="0.01" value={p.base_price ?? ''} onchange={(e) => saveProductPrice(p, e.currentTarget.value)} /></td>
|
||||
<td>{p.active ? 'Yes' : 'No'}</td>
|
||||
<td><button class="link" onclick={() => toggleProductActive(p)}>{p.active ? 'Disable' : 'Enable'}</button></td>
|
||||
<td>{l.product_name}</td>
|
||||
<td class="amt">{l.quantity}</td>
|
||||
<td class="amt">{l.requires_quote ? 'Quote' : money(l.resolved_unit_price ?? l.unit_price)}</td>
|
||||
<td>
|
||||
<input
|
||||
class="ovr"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder={l.admin_override_price != null ? String(l.admin_override_price) : 'set'}
|
||||
onchange={(e) => overrideLine(l.id, e.currentTarget.value)}
|
||||
/>
|
||||
</td>
|
||||
<td class="amt">{money(l.line_total)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="detail-total"><span>Subtotal (ex GST)</span><strong>{money(selectedOrder.subtotal_ex_gst)}</strong></div>
|
||||
|
||||
{#if tab === 'customers'}
|
||||
<div class="split">
|
||||
<section class="surface-card">
|
||||
<h2>New customer</h2>
|
||||
<div class="form-row">
|
||||
<input placeholder="Company name" bind:value={newCustomer.name} />
|
||||
<input placeholder="Code (e.g. ACME)" bind:value={newCustomer.client_code} />
|
||||
<button class="primary" onclick={createCustomer}>Create</button>
|
||||
</div>
|
||||
<h2 class="mt">Customers ({customers.length})</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Code</th><th>Users</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each customers as c (c.id)}
|
||||
<tr class:selected={selectedCustomer?.id === c.id}>
|
||||
<td><button class="link" onclick={() => openCustomer(c)}>{c.name}</button></td>
|
||||
<td>{c.client_code}</td>
|
||||
<td>{c.user_count}</td>
|
||||
<td><span class="pill">{c.status}</span></td>
|
||||
<td><button class="link" onclick={() => toggleCustomerStatus(c)}>{c.status === 'active' ? 'Disable' : 'Enable'}</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{#if selectedCustomer}
|
||||
<section class="surface-card detail">
|
||||
<h2>{selectedCustomer.name}</h2>
|
||||
|
||||
<h3>Users</h3>
|
||||
<ul class="mini">
|
||||
{#each custUsers as u (u.id)}
|
||||
<li>{u.full_name} · {u.email} · {u.role} · {u.status}
|
||||
<button class="link" onclick={() => toggleUserStatus(u)}>{u.status === 'suspended' ? 'Reactivate' : 'Suspend'}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="form-row">
|
||||
<input placeholder="Full name" bind:value={newUser.full_name} />
|
||||
<input placeholder="Email" bind:value={newUser.email} />
|
||||
<select bind:value={newUser.role}>
|
||||
<option value="owner">Owner</option><option value="buyer">Buyer</option>
|
||||
<option value="accounts">Accounts</option><option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="secondary" onclick={addUser}>Invite</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt">Pricing</h3>
|
||||
<div class="form-row">
|
||||
<label class="inline-label">Default discount %
|
||||
<input type="number" step="0.5" bind:value={discountInput} />
|
||||
</label>
|
||||
<button class="secondary" onclick={saveDiscount}>Save discount</button>
|
||||
</div>
|
||||
{#if custPricing?.product_prices.length}
|
||||
<ul class="mini">
|
||||
{#each custPricing.product_prices as pp (pp.id)}
|
||||
<li>{productName(pp.product_id)} · {pp.rule_type} · {pp.unit_price != null ? money(pp.unit_price) : 'quote'}
|
||||
<button class="link" onclick={() => removeProductPrice(pp.product_id)}>Remove</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="form-row">
|
||||
<select bind:value={newPrice.product_id}>
|
||||
<option value="">Product…</option>
|
||||
{#each products as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={newPrice.rule_type}>
|
||||
<option value="fixed">Fixed</option><option value="contract">Contract</option><option value="quote">Quote</option>
|
||||
</select>
|
||||
<input type="number" step="0.01" placeholder="Unit price" bind:value={newPrice.unit_price} disabled={newPrice.rule_type === 'quote'} />
|
||||
<button class="secondary" onclick={addProductPrice}>Set price</button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt">Product visibility</h3>
|
||||
<ul class="mini visibility">
|
||||
{#each custVisibility as row (row.product_id)}
|
||||
{#if selectedOrder.status_history?.length}
|
||||
<details class="history">
|
||||
<summary>Status history ({selectedOrder.status_history.length})</summary>
|
||||
<ul>
|
||||
{#each selectedOrder.status_history as h}
|
||||
<li>
|
||||
<label class="check"><input type="checkbox" checked={row.visible} onchange={() => toggleVisibility(row)} /> {row.name}</label>
|
||||
{label(h.from_status ?? 'new')} → {label(h.to_status)} ·
|
||||
{h.actor_name ?? h.actor_type} ·
|
||||
{new Date(h.created_at).toLocaleString('en-AU')}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'settings'}
|
||||
<div class="split">
|
||||
<section class="surface-card">
|
||||
<h2>Notification settings</h2>
|
||||
{#if settings}
|
||||
<div class="form-grid">
|
||||
<label class="full">Internal recipients (comma separated)<input bind:value={settings.internal_recipients} /></label>
|
||||
<label class="full">From email<input bind:value={settings.from_email} /></label>
|
||||
<label class="check"><input type="checkbox" bind:checked={settings.send_customer_confirmation} /> Send customer confirmation</label>
|
||||
<label class="check"><input type="checkbox" bind:checked={settings.require_po_number} /> Require PO number on submit</label>
|
||||
<button class="primary" onclick={saveSettings}>Save settings</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Xero integration</h2>
|
||||
{#if xero}
|
||||
<p class="muted">Mode: <strong>{xero.connection.mode}</strong> · {xero.connection.configured ? 'Configured' : 'Not configured (stub mode)'}</p>
|
||||
{#if xero.connection.missing_env.length}
|
||||
<p class="muted">Missing env: {xero.connection.missing_env.join(', ')}</p>
|
||||
{/if}
|
||||
<h3>Recent syncs</h3>
|
||||
{#if !xero.recent_syncs.length}
|
||||
<p class="empty">No Xero submissions yet.</p>
|
||||
{:else}
|
||||
<ul class="mini">
|
||||
{#each xero.recent_syncs as s (s.id)}
|
||||
<li>Order {s.order_id} · {s.status} · {s.xero_invoice_id ?? '—'} · {new Date(s.created_at).toLocaleString('en-AU')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section class="surface-card detail empty-detail">
|
||||
<span class="empty-icon" aria-hidden="true"><ClipboardList size={26} strokeWidth={1.7} /></span>
|
||||
<h2>Select an order</h2>
|
||||
<p class="muted">Choose an order from the queue to review its lines, adjust pricing, and move it through fulfilment.</p>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-ordering { display: grid; gap: 1rem; }
|
||||
h1 { margin: 0.15rem 0; font-size: 1.4rem; }
|
||||
h2 { margin: 0 0 0.7rem; font-size: 1.05rem; }
|
||||
h3 { margin: 0 0 0.4rem; font-size: 0.92rem; }
|
||||
.mt { margin-top: 1rem; }
|
||||
.eyebrow { color: #6e8576; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.muted { color: #64776b; font-size: 0.84rem; margin: 0 0 0.6rem; }
|
||||
.surface-card { border: 1px solid rgba(34,54,45,0.12); border-radius: 1rem; background: rgba(255,255,255,0.92); padding: 1.1rem; }
|
||||
.tabs { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.tabs button { padding: 0.45rem 0.9rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.7rem; background: transparent; cursor: pointer; font-weight: 600; font-size: 0.86rem; }
|
||||
.tabs button.active { background: var(--color-brand, #2f6f4f); color: #fff; border-color: transparent; }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid rgba(34,54,45,0.08); }
|
||||
th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: #7c8c82; }
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr.selected { background: rgba(47,111,79,0.08); }
|
||||
.pill { padding: 0.16rem 0.5rem; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: rgba(34,54,45,0.08); }
|
||||
.tag { margin-left: 0.4rem; font-size: 0.64rem; padding: 0.05rem 0.35rem; border-radius: 999px; background: #fdf0d5; color: #8a5a00; }
|
||||
.empty { color: #7c8c82; font-size: 0.85rem; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; align-items: end; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.form-grid label, .form-row label { display: grid; gap: 0.2rem; font-size: 0.76rem; color: #64776b; }
|
||||
.form-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.6rem; }
|
||||
.form-row input, .form-row select, .form-grid input, .form-grid select { padding: 0.45rem 0.55rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; font: inherit; }
|
||||
.check { display: flex; flex-direction: row; align-items: center; gap: 0.4rem; }
|
||||
.inline-label { flex-direction: row; align-items: center; gap: 0.45rem; }
|
||||
.inline { width: 6rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
|
||||
.ovr { width: 5rem; padding: 0.3rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.45rem; }
|
||||
.primary, .secondary { border-radius: 0.6rem; padding: 0.5rem 0.9rem; font-weight: 600; cursor: pointer; border: none; }
|
||||
.primary { background: var(--color-brand, #2f6f4f); color: #fff; }
|
||||
.secondary { background: rgba(34,54,45,0.08); color: #22362d; }
|
||||
.link { background: none; border: none; color: var(--color-brand, #2f6f4f); cursor: pointer; font-size: 0.8rem; padding: 0; }
|
||||
.lines td { font-size: 0.82rem; }
|
||||
.detail-total { display: flex; justify-content: space-between; padding: 0.5rem 0; border-top: 1px solid rgba(34,54,45,0.12); margin: 0.4rem 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||||
.actions select { padding: 0.45rem; border: 1px solid rgba(34,54,45,0.15); border-radius: 0.55rem; }
|
||||
.history { margin-top: 0.8rem; font-size: 0.8rem; }
|
||||
.mini { list-style: none; margin: 0.3rem 0 0.6rem; padding: 0; display: grid; gap: 0.3rem; font-size: 0.82rem; }
|
||||
.mini li { display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; }
|
||||
.visibility li { justify-content: flex-start; }
|
||||
@media (max-width: 1000px) { .split, .form-grid { grid-template-columns: 1fr; } }
|
||||
/* Right-align numeric columns for clean scanning. */
|
||||
.amt {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clickable tbody tr:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* The header already carries the divider, so the title row drops its own. */
|
||||
.head-row {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.order-toolbar {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getStoredClientSession, hasStoredClientSession } from '$lib/session';
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import { canManageOrdering, getWorkspaceHomeHref } from '$lib/workspace-access';
|
||||
|
||||
const EMPTY = { orders: [], products: [], customers: [], xero: null } as const;
|
||||
import type { Order } from '$lib/types';
|
||||
|
||||
// Orders queue. Access is already enforced by the family +layout.ts guard.
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { ...EMPTY };
|
||||
}
|
||||
|
||||
const session = getStoredClientSession();
|
||||
if (!canManageOrdering(session)) {
|
||||
// Customers (or anyone without manage rights) don't belong here.
|
||||
throw redirect(307, getWorkspaceHomeHref(session));
|
||||
return { orders: [] as Order[] };
|
||||
}
|
||||
|
||||
try {
|
||||
const [orders, products, customers, xero] = await Promise.all([
|
||||
api.orderingAdmin.orders(undefined, fetch),
|
||||
api.orderingAdmin.products(fetch),
|
||||
api.orderingAdmin.customers(fetch),
|
||||
api.orderingAdmin.xeroStatus(fetch)
|
||||
]);
|
||||
return { orders, products, customers, xero };
|
||||
const orders = await api.orderingAdmin.orders(undefined, fetch);
|
||||
return { orders };
|
||||
} catch {
|
||||
return { ...EMPTY };
|
||||
return { orders: [] as Order[] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Building2, Plus } from 'lucide-svelte';
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { statusTone } from '$lib/ordering/format';
|
||||
import CustomerWorkspace from '$lib/components/ordering/CustomerWorkspace.svelte';
|
||||
import type { OrderingCustomer } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let customers = $state<OrderingCustomer[]>([]);
|
||||
$effect(() => {
|
||||
customers = data.customers ?? [];
|
||||
});
|
||||
|
||||
// ── Customer list: search + inline create ──────────────────────────────────
|
||||
let listQuery = $state('');
|
||||
const filteredCustomers = $derived.by(() => {
|
||||
const q = listQuery.trim().toLowerCase();
|
||||
if (!q) return customers;
|
||||
return customers.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.client_code.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
let newCustomer = $state({ name: '', client_code: '' });
|
||||
let showNewCustomer = $state(false);
|
||||
let newCustomerNameInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function toggleNewCustomer() {
|
||||
showNewCustomer = !showNewCustomer;
|
||||
if (showNewCustomer) newCustomer = { name: '', client_code: '' };
|
||||
}
|
||||
$effect(() => {
|
||||
if (showNewCustomer) tick().then(() => newCustomerNameInput?.focus());
|
||||
});
|
||||
|
||||
let selectedCustomer = $state<OrderingCustomer | null>(null);
|
||||
|
||||
function openCustomer(c: OrderingCustomer) {
|
||||
selectedCustomer = c;
|
||||
}
|
||||
function closeDetail() {
|
||||
selectedCustomer = null;
|
||||
}
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (showNewCustomer) showNewCustomer = false;
|
||||
else if (selectedCustomer) closeDetail();
|
||||
}
|
||||
|
||||
async function refreshCustomers(updated?: OrderingCustomer) {
|
||||
if (updated && selectedCustomer?.id === updated.id) selectedCustomer = updated;
|
||||
try {
|
||||
customers = await api.orderingAdmin.customers();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function createCustomer() {
|
||||
if (!newCustomer.name || !newCustomer.client_code) return toast.error('Name and code are required.');
|
||||
try {
|
||||
await api.orderingAdmin.createCustomer(newCustomer);
|
||||
toast.success('Customer created.');
|
||||
newCustomer = { name: '', client_code: '' };
|
||||
showNewCustomer = false;
|
||||
await refreshCustomers();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not create customer.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
<div class="console-split">
|
||||
<!-- ── Left: customer roster ─────────────────────────────────────────────── -->
|
||||
<section class="surface-card">
|
||||
<div class="card-head">
|
||||
<h2>Customers <span class="count">{filteredCustomers.length}</span></h2>
|
||||
<button class="primary" onclick={toggleNewCustomer}>
|
||||
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||
{showNewCustomer ? 'Cancel' : 'New customer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showNewCustomer}
|
||||
<div class="create-panel">
|
||||
<div class="form-grid">
|
||||
<label class="full">Company name
|
||||
<input bind:this={newCustomerNameInput} bind:value={newCustomer.name} />
|
||||
</label>
|
||||
<label class="full">Client code
|
||||
<input placeholder="e.g. ACME" bind:value={newCustomer.client_code} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" onclick={toggleNewCustomer}>Cancel</button>
|
||||
<button class="primary" onclick={createCustomer}>Create customer</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input class="list-search" type="search" placeholder="Search name or code" bind:value={listQuery} />
|
||||
|
||||
{#if filteredCustomers.length}
|
||||
<table class="clickable">
|
||||
<thead>
|
||||
<tr><th>Customer</th><th class="amt">Users</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredCustomers as c (c.id)}
|
||||
<tr
|
||||
class:selected={selectedCustomer?.id === c.id}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-pressed={selectedCustomer?.id === c.id}
|
||||
onclick={() => openCustomer(c)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openCustomer(c);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<div class="id-cell">
|
||||
<span class="id-name">{c.name}</span>
|
||||
<span class="id-sub">{c.client_code}{c.discount_percent ? ` · ${c.discount_percent}% off` : ''}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="amt">{c.user_count}</td>
|
||||
<td><span class="pill {statusTone(c.status)}">{c.status}</span></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if customers.length}
|
||||
<p class="empty">No customers match “{listQuery}”.</p>
|
||||
{:else}
|
||||
<p class="empty">No customers yet. Create one to start managing access.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ── Right: customer workspace ─────────────────────────────────────────── -->
|
||||
{#if selectedCustomer}
|
||||
<CustomerWorkspace customer={selectedCustomer} onChanged={refreshCustomers} onClose={closeDetail} />
|
||||
{:else}
|
||||
<section class="surface-card detail empty-detail">
|
||||
<span class="empty-icon" aria-hidden="true"><Building2 size={26} strokeWidth={1.7} /></span>
|
||||
<h2>Select a customer</h2>
|
||||
<p class="muted">
|
||||
Pick a company to open its workspace: details, people, catalogue access, orders, mixes, and history in one place.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.amt { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.primary { gap: 0.4rem; }
|
||||
.primary :global(svg) { display: block; }
|
||||
|
||||
.clickable tbody tr:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { hasStoredClientSession } from '$lib/session';
|
||||
import { api } from '$lib/api';
|
||||
import type { OrderingCustomer } from '$lib/types';
|
||||
|
||||
// Customer accounts. Access enforced by the family +layout.ts guard.
|
||||
export async function load({ fetch }) {
|
||||
if (!hasStoredClientSession()) {
|
||||
return { customers: [] as OrderingCustomer[] };
|
||||
}
|
||||
|
||||
try {
|
||||
const customers = await api.orderingAdmin.customers(fetch);
|
||||
return { customers };
|
||||
} catch {
|
||||
return { customers: [] as OrderingCustomer[] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { Plug } from 'lucide-svelte';
|
||||
|
||||
// Connected systems. Each integration has its own page nested under this one
|
||||
// (and a matching submenu entry in the rail).
|
||||
const integrations = [
|
||||
{
|
||||
href: '/ordering/manage/integrations/xero',
|
||||
name: 'Xero',
|
||||
description: 'Send confirmed order invoices to Xero and map customers to their Xero contact.'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="surface-card">
|
||||
<h2>Integrations</h2>
|
||||
<p class="muted">Connect the ordering portal to the systems you already use.</p>
|
||||
<ul class="integration-list">
|
||||
{#each integrations as it (it.href)}
|
||||
<li>
|
||||
<a class="integration" href={it.href}>
|
||||
<span class="ico"><Plug size={18} strokeWidth={1.75} /></span>
|
||||
<span class="body">
|
||||
<span class="name">{it.name}</span>
|
||||
<span class="desc">{it.description}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.integration-list { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.6rem; }
|
||||
.integration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--panel-soft);
|
||||
transition: border-color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
.integration:hover { border-color: var(--color-brand); background: var(--color-surface-hover); }
|
||||
.ico {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-brand);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.body { display: grid; gap: 0.15rem; min-width: 0; }
|
||||
.name { font-weight: 700; font-size: 0.95rem; color: var(--color-text-primary); }
|
||||
.desc { font-size: 0.82rem; color: var(--color-text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Integrations landing. Individual integrations (Xero) load their own data on
|
||||
// their nested routes. Access enforced by the family +layout.ts guard.
|
||||
export function load() {
|
||||
return {};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user