Compare commits

..
16 Commits
Author SHA1 Message Date
adminandClaude Opus 4.8 5a4d9d77e5 v0.1.36 - What's new dialog: historical changelog accordion
Add a "Read previous changes" accordion to the What's new dialog and
backfill the changelog history from git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:36:17 +12:00
adminandClaude Opus 4.8 dc50e0538e v0.1.35 - Ingredient categories: created categories now available across all rows
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:28:23 +12:00
admin c9f233dc0e v0.1.34 2026-06-21 12:16:41 +12:00
admin 87878e70fc v0.1.32 - Mix Calculator search, ingredient categories, throughput tidy-up
Mix Calculator: searchable Mix Name picker (mirrors Throughput search)

Ingredients Editor: add manual Category column; used to order Mix Calculator output

Mix Calculator: surface formula-only mixes (no product yet) via -mix_id sentinel

Throughput: remove unused For order / For stock destination controls from composer

Editor change history: show timestamps in local time (stored UTC) instead of raw UTC
2026-06-21 11:57:14 +12:00
adminandClaude Opus 4.8 696f1e7b09 v0.1.32 - Mix Calculator search, ingredient categories, throughput tidy-up
- Mix Calculator: searchable Mix Name picker (mirrors Throughput search)
- Ingredients Editor: add manual Category column; used to order Mix Calculator output
- Mix Calculator: surface formula-only mixes (no product yet) via -mix_id sentinel
- Throughput: remove unused For order / For stock destination controls from composer
- Editor change history: show timestamps in local time (stored UTC) instead of raw UTC

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:09:43 +12:00
admin 10722a65a6 v0.1.31 - Mix Editor multi edit 2026-06-18 15:15:46 +12:00
adminandClaude Opus 4.8 1062c038e8 v0.1.30 - Throughput overview today-only mix cards; mix formula save 500 fix
Throughput Overview: Horse Mix and Grain Mix are now the first two cards and
show TODAY's output only. Removed the 7d/4w/6w/12w range selector; the cards
are fixed to Horse mix today, Grain mix today, Today, This week, 4-week average.

Mix Editor formula save: fix HTTP 500 on PUT /editor/mixes/{id}/formula. The
audit-diff path read the resolved formula by attribute, but the resolver returns
dicts -> AttributeError. Read by key and expire stale ORM state so the response
reflects the just-saved rows. Adds regression tests for both save branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:04:49 +12:00
adminandClaude Opus 4.8 e7a7b11589 v0.1.29 - Mix Editor % edits no longer rebalance other ingredients
Editing one ingredient's % now converts only that row to kg against the
Total mix anchor; other ingredients are left untouched (no proportional
redistribution). Removing a row likewise leaves the rest as-is. The hard
"percentages must total 100%" save guard is relaxed (kg is canonical and
the backend does not require 100%); the % chip remains as live feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:49:15 +12:00
adminandClaude Opus 4.8 1dd48bc771 v0.1.28 - Version bump and editor/throughput updates
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:51:29 +12:00
admin 3f8279af10 v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
2026-06-17 21:55:04 +12:00
admin 7db95e2027 v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
2026-06-16 14:43:17 +12:00
admin 8f9a7b8193 Release v0.1.23 2026-06-15 11:59:04 +12:00
admin 250d6ab6a9 Release v0.1.22 2026-06-15 10:13:02 +12:00
adminandClaude Opus 4.8 8b81f804f7 v0.1.21 - Mix calculator composer restyle
Mirror the throughput composer's green design language on the mix calculator
inputs: brand-tinted composer surface with a calculator-icon header, green
48px fields, Mix Name disabled until a client is chosen, a darker readable
on-brand hint, and optional notes via the "+ Add a note" reveal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:26:07 +12:00
adminandClaude Opus 4.8 b1c0d3f3da v0.1.20 - Even composer fields on laptops & add-success modal
- Throughput: even three-across layout for the "Add a packing run" fields
  on laptop widths so placeholders stay readable in the narrow content band
- Throughput: centred "Added" confirmation modal with in-card confetti on
  save (3s), composer clears for the next entry
- Fix stray character in changelog.ts type comment

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:25:52 +12:00
adminandClaude Opus 4.8 2de82776cb v0.1.19 - Throughput overview & responsive header
- Throughput: new "Throughput Overview" header with Gauge icon and brand-green
  badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix)
- Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks)
  driving the customer-mix cards; stats window widened to 12 weeks so switching
  range is a pure client-side re-filter
- Throughput: cards collapse to a single even 5-across row on laptop and up,
  with container-query value text that scales to each card's width
- Throughput: date logic pinned to Australian Eastern time (fixes the day-early
  date); This week subtitle shows the Mon-Sun date range
- Throughput: subtler tinted add-form; removed the inline-entry kicker and the
  "Open full form" link
- Topbar: fix cramped laptop header - action toggles no longer wrap above the
  user button; search drops to its own row earlier

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 10:01:10 +12:00
115 changed files with 18348 additions and 4202 deletions
+7
View File
@@ -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
+5
View File
@@ -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 300500 lines, split components.
### Dependencies
Current app dependency entry points:
+547 -19
View File
@@ -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
View File
@@ -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]
+162 -3
View File
@@ -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)
+78
View File
@@ -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()]
+53 -2
View File
@@ -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(
+8
View File
@@ -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
+65 -2
View File
@@ -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,
)
+9
View File
@@ -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",
},
}
+2
View File
@@ -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",
+34
View File
@@ -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)
+2
View File
@@ -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")
+28
View File
@@ -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"
+7 -1
View File
@@ -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)
+175
View File
@@ -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
+3
View File
@@ -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
+12
View File
@@ -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"}
+11
View File
@@ -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
+97
View File
@@ -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()
)
+4 -1
View File
@@ -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:
+280 -26
View File
@@ -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"]
+375 -2
View File
@@ -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,
}
+80 -11
View File
@@ -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:
+4 -3
View File
@@ -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",
+204
View File
@@ -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
+88
View File
@@ -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) == []
+7 -1
View File
@@ -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"]
+240
View File
@@ -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"
+115
View File
@@ -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"]
+108
View File
@@ -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)
+100
View File
@@ -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
+15
View File
@@ -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 = @"
+19 -11
View File
@@ -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
+11 -1
View File
@@ -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;
+3
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.14",
"version": "0.1.36",
"private": true,
"type": "module",
"scripts": {
+100
View File
@@ -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: 'Whats 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
View File
@@ -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')
}
};
+155 -7
View File
@@ -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 mixs 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;
+683
View File
@@ -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>
+114 -11
View File
@@ -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>&copy; {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>&copy; {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 ?? ''));
}
+225 -22
View File
@@ -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',
+63
View File
@@ -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;
+438
View File
@@ -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; }
}
+68 -19
View File
@@ -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;
}
}
+85
View File
@@ -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>
+97
View File
@@ -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;
}
}
+217
View File
@@ -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;
};
+1
View File
@@ -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);
+3 -3
View File
@@ -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 />
+9 -1
View File
@@ -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,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
+22
View File
@@ -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: [] };
}
}
-11
View File
@@ -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 {};
}
+165 -496
View File
@@ -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>
+7 -20
View File
@@ -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