Compare commits

...
10 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
45 changed files with 6133 additions and 453 deletions
+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,
)
for role in roles
]
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",
)
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])
+285 -7
View File
@@ -8,7 +8,9 @@ from app.db.session import get_db
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,
@@ -26,6 +28,13 @@ from app.schemas.editor import (
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
@@ -76,12 +85,17 @@ 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_count > 0,
"visible": visible,
"product_count": product_count,
"visible_product_count": visible_count,
"notes": mix.notes,
@@ -126,6 +140,54 @@ def _serialize_mix_formula(mix: Mix) -> dict:
}
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)
@@ -265,8 +327,20 @@ def create_editor_mix(
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).
@@ -285,17 +359,62 @@ def update_editor_mix(
raise HTTPException(status_code=404, detail="Mix not found")
updates = payload.model_dump(exclude_unset=True)
# `visible` is a virtual field: it fans out to the visibility of every product
# under the mix rather than mapping to a mix column.
# `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()
@@ -304,6 +423,45 @@ def update_editor_mix(
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,
@@ -326,7 +484,10 @@ def add_editor_mix_ingredient(
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")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
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(
@@ -338,6 +499,15 @@ def add_editor_mix_ingredient(
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:
@@ -367,8 +537,22 @@ def update_editor_mix_ingredient(
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items():
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 "")
@@ -393,7 +577,18 @@ def delete_editor_mix_ingredient(
)
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 "")
@@ -437,6 +632,9 @@ def replace_editor_mix_formula(
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")
@@ -483,9 +681,38 @@ def replace_editor_mix_formula(
)
)
db.commit()
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)
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
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)
@@ -611,6 +838,7 @@ def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
"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,
@@ -658,10 +886,20 @@ def create_editor_ingredient(
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()
@@ -692,8 +930,35 @@ def update_editor_ingredient(
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:
@@ -702,3 +967,16 @@ def update_editor_ingredient(
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]
+16
View File
@@ -10,6 +10,7 @@ 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,
@@ -216,6 +217,21 @@ def import_entries(
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,
+63 -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"
@@ -130,6 +139,7 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
("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"),
)
@@ -434,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,
)
+1
View File
@@ -117,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
+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)
+3
View File
@@ -18,6 +18,9 @@ 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)
+27
View File
@@ -188,6 +188,8 @@ class EditorIngredientRow(BaseModel):
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
@@ -206,6 +208,7 @@ class EditorIngredientCreate(BaseModel):
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)
@@ -218,4 +221,28 @@ class EditorIngredientUpdate(BaseModel):
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
+2
View File
@@ -27,6 +27,8 @@ class MixCalculatorSessionLineRead(BaseModel):
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
+4
View File
@@ -124,6 +124,10 @@ class ThroughputImportResult(BaseModel):
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()
)
+182 -23
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 = [
@@ -44,6 +71,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"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
@@ -57,6 +85,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
"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)
@@ -64,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)
@@ -183,29 +234,32 @@ def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict
}
def calculate_mix_calculator_preview(
db: Session,
def _scale_preview(
*,
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")
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)
# 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)
@@ -221,24 +275,24 @@ def calculate_mix_calculator_preview(
"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",
@@ -248,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.
@@ -296,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,
@@ -311,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}
@@ -396,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",
+44 -3
View File
@@ -4,6 +4,7 @@ import csv
import io
import logging
import os
import re
from datetime import date, datetime
from pathlib import Path
from typing import Iterable
@@ -149,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):
@@ -159,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:
@@ -167,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:
@@ -571,6 +608,10 @@ def import_entries_from_file(
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] = {}
@@ -596,7 +637,7 @@ def import_entries_from_file(
if not row or all(value is None or str(value).strip() == "" for value in row):
continue
production_date = _coerce_date(cell(row, "date"))
production_date = _coerce_date(cell(row, "date"), date_formats)
product_name = _coerce_text(cell(row, "product"))
quantity = _coerce_float(cell(row, "quantity"))
+3 -3
View File
@@ -3,9 +3,9 @@ requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "data-entry-app-backend"
version = "0.1.19"
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",
+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"]
+139 -1
View File
@@ -7,13 +7,19 @@ product is chosen the way the calculator chooses it.
"""
from __future__ import annotations
from sqlalchemy import create_engine
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,
@@ -22,6 +28,17 @@ from app.services.mix_calculator_service import (
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)
@@ -82,6 +99,127 @@ def test_falls_back_to_mix_master_when_no_product_formula():
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")
+54
View File
@@ -266,6 +266,60 @@ def test_upload_import_keeps_blank_destination_flags_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 = (
+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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hunter-app",
"version": "0.1.26",
"version": "0.1.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hunter-app",
"version": "0.1.26",
"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.26",
"version": "0.1.36",
"private": true,
"type": "module",
"scripts": {
+58
View File
@@ -7,6 +7,14 @@ import type {
ClientUserCreateInput,
ClientUserModulePermission,
ClientUserUpdateInput,
InternalUser,
InternalRoleOption,
InternalRole,
InternalRoleCreateInput,
InternalRoleModuleDefinition,
InternalRoleUpdateInput,
InternalUserCreateInput,
InternalUserUpdateInput,
LoginResponse,
EditorMixCreateInput,
EditorMixUpdateInput,
@@ -17,6 +25,7 @@ import type {
EditorIngredientRow,
EditorIngredientCreateInput,
EditorIngredientUpdateInput,
EditorChangeEvent,
EditorProductFormula,
EditorProductRow,
EditorProductUpdateInput,
@@ -49,6 +58,7 @@ import type {
XeroContactList,
XeroContactLinkRow,
Scenario,
ThroughputDeleteAllResult,
ThroughputEntry,
ThroughputEntryCreateInput,
ThroughputEntryUpdateInput,
@@ -400,6 +410,10 @@ export const api = {
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
@@ -453,6 +467,10 @@ export const api = {
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) =>
@@ -505,6 +523,8 @@ export const api = {
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',
@@ -541,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',
+110
View File
@@ -17,6 +17,94 @@ 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',
@@ -24,6 +112,13 @@ export const changelog: ChangelogEntry[] = [
'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',
@@ -32,6 +127,14 @@ export const changelog: ChangelogEntry[] = [
'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',
@@ -65,6 +168,13 @@ export const changelog: ChangelogEntry[] = [
'Throughput tab ready for testing',
'Costing Editor tab ready for testing'
]
},
{
version: '0.1.11',
date: '2026-06-03',
highlights: [
'Costing Editor: First release.'
]
}
];
+8 -6
View File
@@ -133,21 +133,23 @@
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 (costing
// tools plus throughput), then the standalone ordering/insights modules. Built
// from the same access-filtered items, so a role only ever sees the families it
// may open.
// 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,
costing: [
operations: [
...(visibleMixCalculatorItem ? [visibleMixCalculatorItem] : []),
...(visibleThroughputItem ? [visibleThroughputItem] : [])
],
costing: [
...(visibleProductCostingItem ? [visibleProductCostingItem] : []),
...(visibleEditorItem ? [visibleEditorItem] : []),
...(visibleIngredientsEditorItem ? [visibleIngredientsEditorItem] : []),
...visibleWorkingDocumentItems
],
throughput: visibleThroughputItem,
ordering: visibleOrderingEntry,
reporting: visibleReportingItem
})
@@ -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,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>
@@ -13,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();
@@ -304,7 +305,7 @@
<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>
@@ -344,18 +345,12 @@
<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>
@@ -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>
@@ -16,11 +16,12 @@
} = $props();
// ── Ingredient sorting ──────────────────────────────────────────
// Default to heaviest ingredient first; clicking a header toggles direction
// (or switches column). Required kg starts descending, the name ascending.
type LineSortKey = 'raw_material_name' | 'required_kg';
let sortKey = $state<LineSortKey>('required_kg');
let sortDir = $state<'asc' | 'desc'>('desc');
// 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) {
@@ -39,10 +40,16 @@
const sortedLines = $derived.by(() => {
const dir = sortDir === 'asc' ? 1 : -1;
return [...(preview?.lines ?? [])].sort((a, b) => {
const result =
sortKey === 'required_kg'
? (a.required_kg ?? 0) - (b.required_kg ?? 0)
: a.raw_material_name.localeCompare(b.raw_material_name, undefined, { sensitivity: 'base' });
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;
});
});
@@ -108,6 +115,17 @@
<table>
<thead>
<tr>
<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"
@@ -135,6 +153,9 @@
<tbody>
{#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>
@@ -321,6 +342,11 @@
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;
@@ -349,6 +349,7 @@
{@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"
@@ -356,6 +357,7 @@
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>
@@ -374,12 +376,12 @@
{#if subOpen}
<div class="rail-children rail-subchildren">
{#each child.children as grandchild}
{@render leafLink(grandchild, false)}
{@render leafLink(grandchild, true)}
{/each}
</div>
{/if}
{:else}
{@render leafLink(child, false)}
{@render leafLink(child, true)}
{/if}
{/each}
</div>
@@ -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>
@@ -115,42 +115,6 @@
<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-dest">
<span class="cell-label">Destination</span>
<div class="dest-rows">
<div class="dest-line">
<label class="dest-toggle" class:on={nForOrder}>
<input type="checkbox" bind:checked={nForOrder} /> For an order
</label>
{#if nForOrder}
<input
class="dest-input"
type="text"
bind:value={nJobNumber}
placeholder="Job number (Order Circle)"
aria-label="Job number"
/>
{/if}
</div>
<div class="dest-line">
<label class="dest-toggle" class:on={nForStock}>
<input type="checkbox" bind:checked={nForStock} /> For stock
</label>
{#if isSplit}
<input
class="dest-input"
type="number"
min="0"
step="0.01"
inputmode="decimal"
bind:value={nStockQty}
placeholder={`To stock (${nType === 'bags' ? 'bags' : 'kg'})`}
aria-label="Amount going to stock"
/>
{/if}
</div>
</div>
</div>
<div class="add-cell add-action">
<button type="submit" class="add-entry-button" disabled={saving}>
<Plus size={18} strokeWidth={2.6} />
@@ -218,7 +182,7 @@
.add-row {
display: grid;
grid-template-columns: 7.5rem minmax(19rem, 1.7fr) minmax(14rem, 1.15fr) minmax(7rem, 0.65fr) minmax(14rem, 1.2fr) auto;
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;
@@ -287,68 +251,6 @@
font-variant-numeric: tabular-nums;
}
.add-dest {
gap: 0.4rem;
}
.dest-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dest-line {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dest-line .dest-toggle {
flex: 0 0 auto;
min-width: 8.5rem;
}
.dest-line .dest-input {
flex: 1 1 auto;
min-width: 0;
width: auto;
}
.dest-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.68rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.72rem;
background: var(--color-bg-surface);
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.dest-toggle input {
width: 1.2rem;
height: 1.2rem;
min-height: 0;
margin: 0;
flex-shrink: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.dest-toggle.on {
border-color: var(--color-brand);
background: var(--color-brand-tint);
color: var(--color-success);
}
.dest-input {
width: 100%;
}
.add-action {
justify-content: center;
}
@@ -495,7 +397,6 @@
.add-cell:nth-child(2),
.add-cell:nth-child(3),
.add-dest,
.add-action {
grid-column: 1 / -1;
}
@@ -526,7 +427,6 @@
}
.add-cell:nth-child(2),
.add-dest,
.add-action {
grid-column: 1 / -1;
}
@@ -29,7 +29,6 @@
formatNumber,
packedMain,
packedDetail,
destinationOf,
onApplyFilters,
onClearFilters,
onToggleSort,
@@ -59,7 +58,6 @@
formatNumber: (value: number | null | undefined, digits?: number) => string;
packedMain: (entry: ThroughputEntry) => string;
packedDetail: (entry: ThroughputEntry) => string;
destinationOf: (entry: ThroughputEntry) => { label: string; detail: string | null };
onApplyFilters: () => void;
onClearFilters: () => void;
onToggleSort: (key: SortKey) => void;
@@ -171,10 +169,6 @@
<span>Packed by</span>
<ArrowUpDown size={14} strokeWidth={2.1} />
</button>
<button type="button" class="sort-head" class:active={sortKey === 'destination'} onclick={() => onToggleSort('destination')}>
<span>Destination</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} />
@@ -194,7 +188,6 @@
{/each}
{:else}
{#each paginatedEntries as entry (entry.id)}
{@const dest = destinationOf(entry)}
<div class="row" class:just-added={entry.id === highlightId}>
<span class="col-date">
<span class="cell-label">Date</span>
@@ -217,16 +210,6 @@
<span class="cell-label">Packed by</span>
{entry.staff_name ?? '—'}
</span>
<span class="col-dest">
<span class="cell-label">Destination</span>
<span
class="pill"
class:pill-stock={dest.label === 'Stock'}
class:pill-order={dest.label === 'Order'}
class:pill-split={dest.label === 'Split'}
>{dest.label}</span>
{#if dest.detail}<span class="dest-detail">{dest.detail}</span>{/if}
</span>
<span class="col-actions">
<button
type="button"
@@ -442,7 +425,7 @@
.log-head,
.row {
display: grid;
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem 4.8rem;
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;
}
@@ -494,7 +477,6 @@
}
.col-product,
.col-dest,
.col-packed,
.col-total {
display: flex;
@@ -509,15 +491,13 @@
font-weight: 650;
}
.dest-detail,
.packed-detail {
font-size: 0.88rem;
color: var(--color-text-secondary);
}
.total-kg,
.packed-main,
.dest-detail {
.packed-main {
font-variant-numeric: tabular-nums;
}
@@ -591,32 +571,6 @@
display: none;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.42rem 0.78rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 650;
white-space: nowrap;
}
.pill-stock {
background: #e8f1fc;
color: #0b5cad;
}
.pill-order {
background: var(--color-brand-tint);
color: var(--color-success);
}
.pill-split {
background: #f3e8fc;
color: #6b21a8;
}
.row-skeleton {
padding: 1.15rem 1.45rem;
border-bottom: 1px solid var(--color-divider);
@@ -741,7 +695,7 @@
@media (min-width: 1280px) {
.log-head,
.row {
grid-template-columns: 8rem minmax(0, 1.1fr) minmax(0, 0.95fr) 6rem minmax(0, 0.8fr) 10.5rem minmax(0, 1.2fr) 4.8rem;
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 {
@@ -749,7 +703,7 @@
}
.row-notes {
grid-column: 7;
grid-column: 6;
align-self: center;
margin: 0;
padding-top: 0;
@@ -758,7 +712,7 @@
}
.col-actions {
grid-column: 8;
grid-column: 7;
}
}
@@ -1,14 +1,11 @@
<script lang="ts">
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
import { MIX_RANGES } from '$lib/components/throughput/utils';
let {
today,
weekRangeLabel,
heroStats,
mixTotals,
mixRangeKey = $bindable<(typeof MIX_RANGES)[number]['key']>('4w'),
formatDate,
formatNumber
}: {
@@ -16,7 +13,6 @@
weekRangeLabel: string;
heroStats: { today: number; thisWeek: number; avgFourWeek: number };
mixTotals: { horse: number; grain: number };
mixRangeKey?: (typeof MIX_RANGES)[number]['key'];
formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string;
} = $props();
@@ -26,18 +22,19 @@
<div class="summary-heading">
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
<h2>Throughput Overview</h2>
<div class="range-select" role="group" aria-label="Customer mix date range">
{#each MIX_RANGES as range (range.key)}
<button
type="button"
class="range-option"
class:active={mixRangeKey === range.key}
aria-pressed={mixRangeKey === range.key}
onclick={() => (mixRangeKey = range.key)}
>{range.label}</button>
{/each}
</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>
@@ -55,18 +52,6 @@
<p class="fact-sub">Per week, last 4 weeks</p>
</div>
</dl>
<dl class="mix-facts" aria-label="Throughput by customer">
<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 · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</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 · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
</dl>
</header>
<style>
@@ -106,41 +91,6 @@
color: var(--color-brand);
}
.range-select {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
padding: 0.28rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-bg-surface) 55%, transparent);
}
.range-option {
padding: 0.5rem 0.95rem;
border: 0;
border-radius: 0.6rem;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.range-option:hover {
color: var(--color-text-primary);
}
.range-option.active {
background: var(--color-brand);
color: #fff;
box-shadow: 0 8px 18px -14px color-mix(in srgb, var(--color-brand) 85%, transparent);
}
.facts,
.mix-facts {
display: grid;
@@ -149,8 +99,13 @@
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;
}
@@ -243,12 +198,12 @@
}
@media (max-width: 760px) {
.facts {
.mix-facts {
grid-template-columns: 1fr;
padding: 0.9rem;
}
.mix-facts {
.facts {
grid-template-columns: 1fr;
padding: 0 0.9rem 0.9rem;
}
@@ -15,13 +15,6 @@ export type ConfettiPiece = {
export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444'];
export const MIX_RANGES = [
{ key: '7d', label: '7 days', sub: 'last 7 days', days: 7 },
{ key: '4w', label: '4 weeks', sub: 'last 4 weeks', days: 28 },
{ key: '6w', label: '6 weeks', sub: 'last 6 weeks', days: 42 },
{ key: '12w', label: '12 weeks', sub: 'last 12 weeks', days: 84 }
] as const;
export function compareText(a: string | null | undefined, b: string | null | undefined) {
return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' });
}
@@ -292,15 +292,14 @@ 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 an "Operations" group (the calculator,
* costing, editor, master tools, and throughput), then Ordering and Insights
* modules. Costing tools live inside Operations for the time being until they
* grow into a family of their 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?: NavEntry | null;
reporting?: NavItem | null;
}): NavEntry[] {
@@ -310,14 +309,17 @@ export function buildClientNavEntries(visible: {
entries.push({ kind: 'item', item: visible.dashboard });
}
const operationsChildren = [
...visible.costing,
...(visible.throughput ? [visible.throughput] : [])
];
if (operationsChildren.length) {
if (visible.operations.length) {
entries.push({
kind: 'group',
group: { id: 'operations', label: 'Operations', icon: Layers, children: operationsChildren }
group: { id: 'operations', label: 'Operations', icon: Layers, children: visible.operations }
});
}
if (visible.costing.length) {
entries.push({
kind: 'group',
group: { id: 'costing', label: 'Costing', icon: BadgeDollarSign, children: visible.costing }
});
}
@@ -408,15 +410,15 @@ export function pageMeta(pathname: string): PageMeta {
}
if (pathname.startsWith('/product-costing')) {
return { title: productCostingItem.label, category: 'Operations', icon: productCostingItem.icon };
return { title: productCostingItem.label, category: 'Costing', icon: productCostingItem.icon };
}
if (pathname.startsWith('/editor')) {
return { title: editorItem.label, category: 'Operations', icon: editorItem.icon };
return { title: editorItem.label, category: 'Costing', icon: editorItem.icon };
}
if (pathname.startsWith('/ingredients')) {
return { title: ingredientsEditorItem.label, category: 'Operations', icon: ingredientsEditorItem.icon };
return { title: ingredientsEditorItem.label, category: 'Costing', icon: ingredientsEditorItem.icon };
}
if (pathname.startsWith('/raw-materials')) {
+90
View File
@@ -100,6 +100,7 @@ export type MixCalculatorLine = {
mix_percentage: number;
unit: string;
rounding_decimals?: number;
category?: string | null;
sort_order: number;
};
@@ -401,6 +402,7 @@ export type EditorIngredientRow = {
kg_per_unit: number;
status: string;
rounding_decimals: number;
category: string | null;
notes: string | null;
cost_per_kg: number | null;
usage_count: number;
@@ -414,11 +416,32 @@ export type EditorIngredientCreateInput = {
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;
@@ -592,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;
@@ -716,6 +802,10 @@ export type ThroughputImportResult = {
errors: string[];
};
export type ThroughputDeleteAllResult = {
entries_deleted: number;
};
export type ThroughputEntryListParams = {
date_from?: string;
date_to?: string;
+688 -50
View File
@@ -2,6 +2,7 @@
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
import SortHeader from '$lib/table/SortHeader.svelte';
import { TableController } from '$lib/table/table.svelte';
import type {
@@ -11,7 +12,7 @@
EditorMixUpdateInput,
RawMaterial
} from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { CheckCircle2, ChevronLeft, ChevronRight, EyeOff, FlaskConical, History, ListChecks, ListFilter, Plus, Save, Search, Trash2, TriangleAlert, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
let { data } = $props();
@@ -37,11 +38,21 @@
let expandedMixId = $state<number | null>(null);
let activeFormula = $state<EditorResolvedMixFormula | null>(null);
let ingredientDrafts = $state<DraftIngredient[]>([]);
// Snapshot of the loaded formula, so we can tell whether the open panel has
// unsaved edits before letting the user leave it.
let ingredientBaseline = $state<DraftIngredient[]>([]);
// The reference total used to convert between % and kg. Editing a kg cell
// redefines it (kg is the source of truth); editing the Total mix field
// rescales every row's kg from its %.
let totalReference = $state(0);
// The row the user is trying to open/close while the current panel has unsaved
// ingredient edits (null = no pending switch). Drives the confirm dialog.
let pendingRow = $state<EditableRow | null>(null);
// The mix whose change history is open in the modal (null = closed).
let historyMix = $state<EditableRow | null>(null);
// Inline "create new mix" form state.
let creatingMix = $state(false);
let newMixClient = $state('');
@@ -88,27 +99,26 @@
activeFormula = formula;
totalReference = formula.total_kg || 0;
ingredientDrafts = formula.ingredients.length ? formula.ingredients.map(ingredientToDraft) : [emptyIngredient()];
// Reset the baseline so the freshly loaded (or just-saved) formula reads clean.
ingredientBaseline = ingredientDrafts.map((row) => ({ ...row }));
}
// kg overrides %: recompute the reference total from the kg column, then
// re-derive every row's percentage so they always sum to 100.
function applyKgEdit() {
const total = ingredientDrafts.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0);
totalReference = round4(total);
ingredientDrafts = ingredientDrafts.map((row) => ({
...row,
percentage: total > 0 ? round4((Number(row.quantity_kg || 0) / total) * 100) : 0
}));
}
// % overrides kg: convert this row's percentage to kg against the locked
// reference total. Other rows are untouched, so the percentage total will
// read off 100 until the rest are adjusted (the save guard enforces 100%).
// Editing a row's % converts only that row to kilograms against the Total mix
// anchor. Every other ingredient is left exactly as it is — changing one
// ingredient's share never rebalances or recalculates the rest of the recipe.
// Percentages are free to sum to anything; the chip reports the running total
// and kg stays the canonical saved value.
function applyPercentEdit(index: number) {
const total = totalReference;
const total = Number(totalReference || 0);
// Clamp to a sane 0100 share for the edited row only.
let target = Number(ingredientDrafts[index].percentage || 0);
if (!Number.isFinite(target) || target < 0) target = 0;
if (target > 100) target = 100;
ingredientDrafts = ingredientDrafts.map((row, rowIndex) =>
rowIndex === index
? { ...row, quantity_kg: total > 0 ? round4((Number(row.percentage || 0) / 100) * total) : 0 }
? { ...row, percentage: round4(target), quantity_kg: round4((target / 100) * total) }
: row
);
}
@@ -207,11 +217,28 @@
}
}
// Gate panel switches: if the open formula has unsaved edits, ask before
// leaving it (opening another row, or closing this one, both discard them).
function requestToggleIngredients(row: EditableRow) {
if (expandedMixId !== null && ingredientsDirty) {
pendingRow = row;
return;
}
toggleIngredients(row);
}
function confirmDiscardChanges() {
const target = pendingRow;
pendingRow = null;
if (target) toggleIngredients(target);
}
async function toggleIngredients(row: EditableRow) {
if (expandedMixId === row.id) {
expandedMixId = null;
activeFormula = null;
ingredientDrafts = [];
ingredientBaseline = [];
return;
}
@@ -233,9 +260,10 @@
}
function removeIngredient(index: number) {
// Drop the row and leave the remaining ingredients' % and kg exactly as they
// are — removing one ingredient never recalculates the others.
ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index);
if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()];
applyKgEdit();
}
function ingredientWarnings() {
@@ -252,11 +280,6 @@
if (Number(row.quantity_kg) <= 0) return [`Ingredient row ${index + 1} needs a quantity greater than zero.`];
}
// Percentages must add up to 100 before a change can be saved.
if (Math.abs(percentTotal - 100) > 0.1) {
return [`Percentages must total 100% (currently ${percentTotal.toFixed(2)}%).`];
}
return [];
}
@@ -347,6 +370,107 @@
);
const percentBalanced = $derived(Math.abs(percentTotal - 100) <= 0.1);
// True when the open ingredient panel differs from the formula we loaded.
// kg is the canonical value, so comparing kg (plus raw material and notes)
// captures both % and kg edits.
const ingredientsDirty = $derived.by(() => {
if (ingredientBaseline.length !== ingredientDrafts.length) return true;
return ingredientDrafts.some((row, index) => {
const base = ingredientBaseline[index];
return (
!base ||
row.raw_material_id !== base.raw_material_id ||
round4(Number(row.quantity_kg || 0)) !== round4(Number(base.quantity_kg || 0)) ||
(row.notes ?? '') !== (base.notes ?? '')
);
});
});
// ── Bulk select: tick multiple mixes, then delete or mark them inactive ──
let selectMode = $state(false);
let selectedIds = $state<Set<number>>(new Set());
// Which bulk action is awaiting confirmation (null = no modal open).
let bulkAction = $state<'inactive' | 'delete' | null>(null);
let bulkRunning = $state(false);
// Summary shown after a bulk run finishes (null = closed).
let bulkResult = $state<
{ action: 'inactive' | 'delete'; succeeded: number; failures: { name: string; reason: string }[] } | null
>(null);
// Select-all operates on the rows currently on screen; the selection itself
// persists across pages so a multi-page selection is possible.
const pageRowIds = $derived(table.rows.map((row) => row.id));
const selectedOnPage = $derived(pageRowIds.filter((id) => selectedIds.has(id)).length);
const allPageSelected = $derived(pageRowIds.length > 0 && selectedOnPage === pageRowIds.length);
const somePageSelected = $derived(selectedOnPage > 0 && !allPageSelected);
function toggleSelectMode() {
selectMode = !selectMode;
if (!selectMode) selectedIds = new Set();
}
function toggleRowSelected(id: number) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleSelectAll() {
const next = new Set(selectedIds);
if (allPageSelected) {
for (const id of pageRowIds) next.delete(id);
} else {
for (const id of pageRowIds) next.add(id);
}
selectedIds = next;
}
function requestBulk(action: 'inactive' | 'delete') {
if (selectedIds.size === 0) return;
bulkAction = action;
}
async function runBulk() {
const action = bulkAction;
if (!action) return;
bulkRunning = true;
const targets = rows.filter((row) => selectedIds.has(row.id));
const failures: { name: string; reason: string }[] = [];
const succeededIds = new Set<number>();
for (const row of targets) {
try {
if (action === 'delete') {
await api.deleteEditorMix(row.id);
} else {
applyMixUpdate(await api.updateEditorMix(row.id, { visible: false }));
}
succeededIds.add(row.id);
} catch (error) {
failures.push({ name: row.name, reason: error instanceof Error ? error.message : 'Could not be updated' });
}
}
if (action === 'delete') {
rows = rows.filter((row) => !succeededIds.has(row.id));
// Close the ingredient panel if its mix was just deleted.
if (expandedMixId !== null && succeededIds.has(expandedMixId)) {
expandedMixId = null;
activeFormula = null;
ingredientDrafts = [];
ingredientBaseline = [];
}
}
// Keep only failures selected so the user can see/retry them.
selectedIds = new Set([...selectedIds].filter((id) => !succeededIds.has(id)));
bulkResult = { action, succeeded: succeededIds.size, failures };
bulkAction = null;
bulkRunning = false;
}
// Jump back to the first page whenever the filtered set changes.
$effect(() => {
query;
@@ -436,6 +560,11 @@
</div>
</dl>
<button class="clear-button select-toggle" class:active={selectMode} type="button" onclick={toggleSelectMode}>
<ListChecks size={16} strokeWidth={2.2} />
{selectMode ? 'Done' : 'Select'}
</button>
<button class="apply-button new-mix-button" type="button" onclick={openCreateMix} disabled={creatingMix}>
<Plus size={16} strokeWidth={2.4} />
New mix
@@ -452,11 +581,17 @@
<div class="create-fields">
<label>
<span>Client</span>
<input bind:value={newMixClient} list="editor-client-options" placeholder="Client name" />
<span>Client <span class="req">*</span></span>
<input
bind:value={newMixClient}
list="editor-client-options"
placeholder="Search clients or type a new one"
autocomplete="off"
/>
<small class="field-hint">Pick an existing client from the list, or type a new client name.</small>
</label>
<label>
<span>Mix name</span>
<span>Mix name <span class="req">*</span></span>
<!-- svelte-ignore a11y_autofocus -->
<input bind:value={newMixName} placeholder="Mix name" autofocus />
</label>
@@ -498,16 +633,58 @@
</div>
</div>
<div class="log">
{#if selectMode}
<div class="bulk-bar" transition:fade={{ duration: 120 }} aria-label="Bulk actions">
<div class="bulk-count">
<span class="bulk-badge">{selectedIds.size}</span>
<span>selected</span>
</div>
<div class="bulk-actions">
<button class="clear-button" type="button" disabled={selectedIds.size === 0} onclick={() => requestBulk('inactive')}>
<EyeOff size={16} strokeWidth={2.2} />
Mark inactive
</button>
<button class="danger-button" type="button" disabled={selectedIds.size === 0} onclick={() => requestBulk('delete')}>
<Trash2 size={16} strokeWidth={2.2} />
Delete
</button>
</div>
</div>
{/if}
<div class="log" class:select-mode={selectMode}>
<div class="log-head">
{#if selectMode}
<label class="select-cell">
<input
type="checkbox"
checked={allPageSelected}
indeterminate={somePageSelected}
onchange={toggleSelectAll}
aria-label="Select all mixes on this page"
/>
</label>
{/if}
<SortHeader label="Client" column="client_name" controller={table} />
<SortHeader label="Mix" column="name" controller={table} />
<SortHeader label="Status" column="visible" controller={table} />
<span>Actions</span>
<span class="actions-head">Actions</span>
</div>
{#each table.rows as row (row.id)}
<div class="row" class:edited={rowDirty(row)}>
<div class="row" class:edited={rowDirty(row)} class:selected={selectMode && selectedIds.has(row.id)}>
{#if selectMode}
<label class="select-cell">
<span class="cell-label">Select</span>
<input
type="checkbox"
checked={selectedIds.has(row.id)}
onchange={() => toggleRowSelected(row.id)}
aria-label={`Select ${row.name}`}
/>
</label>
{/if}
<div class="client-cell">
<span class="cell-label">Client</span>
<span class="readonly-value">{row.client_name}</span>
@@ -527,10 +704,14 @@
</div>
<div class="row-actions">
<button class="clear-button" type="button" onclick={() => toggleIngredients(row)}>
<button class="clear-button" type="button" onclick={() => requestToggleIngredients(row)}>
<FlaskConical size={16} strokeWidth={2.2} />
{expandedMixId === row.id ? 'Close ingredients' : savingKey === `mix-load:${row.id}` ? 'Loading...' : 'Ingredients'}
</button>
<button class="clear-button" type="button" onclick={() => (historyMix = row)} aria-label={`History for ${row.name}`}>
<History size={16} strokeWidth={2.2} />
History
</button>
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
<Save size={16} strokeWidth={2.4} />
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save mix'}
@@ -569,7 +750,7 @@
<div class="ingredient-grid">
<span class="grid-label">Raw material</span>
<span class="grid-label">%</span>
<span class="grid-label">kg</span>
<span class="grid-label">kg (auto)</span>
<span class="grid-label">Notes</span>
<span class="grid-label">Remove</span>
@@ -587,14 +768,12 @@
step="0.0001"
aria-label={`Percentage for ${rawMaterialName(ingredient.raw_material_id)}`}
/>
<input
bind:value={ingredient.quantity_kg}
onchange={applyKgEdit}
type="number"
min="0"
step="0.0001"
aria-label={`Quantity for ${rawMaterialName(ingredient.raw_material_id)}`}
/>
<!-- kg is derived from % against the Total mix anchor and stays the
canonical saved value; it is read-only here so % is the single
point of entry (the two cells used to conflict). -->
<span class="kg-readout" aria-label={`Quantity for ${rawMaterialName(ingredient.raw_material_id)}`}>
{Number(ingredient.quantity_kg || 0).toFixed(2)}
</span>
<input bind:value={ingredient.notes} aria-label={`Notes for ${rawMaterialName(ingredient.raw_material_id)}`} />
<button class="clear-button remove-button" type="button" onclick={() => removeIngredient(index)}>Remove</button>
{/each}
@@ -603,7 +782,7 @@
<div class="ingredient-footer">
<span class="footer-total">Total {ingredientTotalKg.toFixed(2)} kg</span>
<button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}` || !percentBalanced} onclick={saveIngredients}>
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}`} onclick={saveIngredients}>
{savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
</button>
</div>
@@ -617,6 +796,133 @@
{/each}
</div>
</section>
{#if historyMix}
<ChangeHistoryModal
entityType="mix"
entityId={historyMix.id}
title={historyMix.name}
subtitle={historyMix.client_name}
onClose={() => (historyMix = null)}
/>
{/if}
{#if pendingRow}
<div class="modal-backdrop" role="presentation" onclick={() => (pendingRow = null)}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="unsaved-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') pendingRow = null; }}
>
<div class="modal-icon"><TriangleAlert size={22} strokeWidth={2.2} /></div>
<h2 id="unsaved-title" class="modal-title">Unsaved changes</h2>
<p class="modal-text">
You have unsaved ingredient changes. Leaving this mix will discard them.
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={() => (pendingRow = null)}>Keep editing</button>
<button type="button" class="modal-confirm" onclick={confirmDiscardChanges}>Discard changes</button>
</div>
</div>
</div>
{/if}
{#if bulkAction}
<div class="modal-backdrop" role="presentation" onclick={() => { if (!bulkRunning) bulkAction = null; }}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape' && !bulkRunning) bulkAction = null; }}
>
<div class="modal-icon" class:danger={bulkAction === 'delete'}>
{#if bulkAction === 'delete'}
<Trash2 size={22} strokeWidth={2.2} />
{:else}
<EyeOff size={22} strokeWidth={2.2} />
{/if}
</div>
<h2 id="bulk-title" class="modal-title">
{bulkAction === 'delete' ? 'Delete' : 'Mark inactive'}
{selectedIds.size}
{selectedIds.size === 1 ? 'mix' : 'mixes'}?
</h2>
<p class="modal-text">
{#if bulkAction === 'delete'}
This permanently removes the selected mixes and their formulas. Any mix that still has linked
products can't be deleted and will be skipped.
{:else}
The selected mixes will be hidden from the active list. You can re-activate them anytime from the
Inactive filter.
{/if}
</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" disabled={bulkRunning} onclick={() => (bulkAction = null)}>Cancel</button>
<button
type="button"
class="modal-confirm"
class:neutral={bulkAction === 'inactive'}
disabled={bulkRunning}
onclick={runBulk}
>
{bulkRunning ? 'Working…' : bulkAction === 'delete' ? 'Delete mixes' : 'Mark inactive'}
</button>
</div>
</div>
</div>
{/if}
{#if bulkResult}
<div class="modal-backdrop" role="presentation" onclick={() => (bulkResult = null)}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-result-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') bulkResult = null; }}
>
<div class="modal-icon" class:danger={bulkResult.failures.length > 0} class:ok={bulkResult.failures.length === 0}>
{#if bulkResult.failures.length === 0}
<CheckCircle2 size={22} strokeWidth={2.2} />
{:else}
<TriangleAlert size={22} strokeWidth={2.2} />
{/if}
</div>
<h2 id="bulk-result-title" class="modal-title">
{bulkResult.action === 'delete' ? 'Delete complete' : 'Update complete'}
</h2>
<p class="modal-text">
{bulkResult.succeeded}
{bulkResult.succeeded === 1 ? 'mix' : 'mixes'}
{bulkResult.action === 'delete' ? 'deleted' : 'marked inactive'}{bulkResult.failures.length
? `, ${bulkResult.failures.length} skipped`
: ''}.
</p>
{#if bulkResult.failures.length}
<ul class="result-failures">
{#each bulkResult.failures as failure}
<li>
<strong>{failure.name}</strong>
<span>{failure.reason}</span>
</li>
{/each}
</ul>
{/if}
<div class="modal-actions">
<button type="button" class="modal-confirm neutral" onclick={() => (bulkResult = null)}>Done</button>
</div>
</div>
</div>
{/if}
</AppSecondaryRailLayout>
<style>
@@ -761,6 +1067,81 @@
flex-shrink: 0;
}
.select-toggle.active {
color: var(--color-brand);
border-color: var(--color-brand);
background: var(--color-brand-tint);
}
/* Bulk action bar: appears above the table while in select mode. */
.bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.6rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 30%, var(--color-border));
border-radius: 0.7rem;
background: var(--color-brand-tint);
}
.bulk-count {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
}
.bulk-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.6rem;
padding: 0 0.45rem;
border-radius: 999px;
background: var(--color-brand);
color: var(--color-on-brand);
font-size: 0.85rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.bulk-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.danger-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-height: 34px;
border-radius: 0.45rem;
padding: 0.45rem 0.65rem;
font-size: 0.86rem;
font-weight: 650;
color: var(--color-on-brand, #fff);
background: var(--color-error);
border: 1px solid var(--color-error);
cursor: pointer;
}
.danger-button:hover {
background: color-mix(in srgb, var(--color-error) 88%, black);
}
.danger-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.facts {
display: flex;
gap: 1.35rem;
@@ -798,6 +1179,19 @@
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
gap: 0.75rem;
align-items: start;
}
.req {
color: var(--color-error);
font-weight: 700;
}
.field-hint {
margin-top: 0.1rem;
color: var(--color-text-muted);
font-size: 0.76rem;
font-weight: 500;
}
.create-actions {
@@ -1046,24 +1440,47 @@
border-color: var(--color-brand);
}
/* The table is one grid that owns the column tracks; the header and every row
are subgrids that share those exact tracks. This is what keeps the headers
lined up with the fields below — separate grids would each size their own
`auto` Actions column from their own content and drift apart. */
.log {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns:
minmax(150px, 1fr)
minmax(200px, 1.6fr)
minmax(96px, 0.5fr)
minmax(198px, auto);
column-gap: 0.55rem;
row-gap: 0;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 0.9rem;
overflow: hidden;
}
/* A leading checkbox column appears in select mode; subgrid rows pick it up
automatically, so header and rows stay aligned. */
.log.select-mode {
grid-template-columns:
2rem
minmax(150px, 1fr)
minmax(200px, 1.6fr)
minmax(96px, 0.5fr)
minmax(198px, auto);
}
.log-head,
.row,
.ingredient-panel,
.empty {
grid-column: 1 / -1;
}
.log-head,
.row {
display: grid;
grid-template-columns:
minmax(170px, 1fr)
minmax(220px, 1.6fr)
minmax(110px, 0.5fr)
minmax(198px, auto);
gap: 0.55rem;
grid-template-columns: subgrid;
align-items: center;
}
@@ -1081,6 +1498,17 @@
letter-spacing: 0;
}
/* Line the header labels and read-only cell text up with the input text,
which sits one input-padding (0.5rem) in from the cell's left edge. The
Actions header instead hugs the right, above the right-aligned buttons. */
.log-head :global(.sort-header) {
padding-left: 0.5rem;
}
.actions-head {
text-align: right;
}
.row {
padding: 0.58rem 0.85rem;
border-bottom: 1px solid var(--color-divider);
@@ -1095,10 +1523,31 @@
background: var(--color-brand-tint);
}
.row.selected {
background: color-mix(in srgb, var(--color-brand) 9%, var(--color-bg-surface));
}
/* Leading checkbox cell (header select-all + per-row select). */
.select-cell {
display: flex;
align-items: center;
justify-content: center;
min-height: 34px;
}
.select-cell input {
width: 1.05rem;
min-height: 1.05rem;
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.readonly-value {
display: flex;
align-items: center;
min-height: 34px;
padding-left: 0.5rem;
color: var(--color-text-primary);
font-size: 0.88rem;
font-weight: 650;
@@ -1127,7 +1576,7 @@
justify-content: flex-start;
gap: 0.3rem;
min-height: 34px;
padding: 0.25rem 0;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 0.42rem;
background: transparent;
@@ -1242,6 +1691,19 @@
color: var(--color-error);
}
/* Read-only kg cell: looks like a quiet field, not an input, so it reads as a
calculated value rather than something editable. */
.kg-readout {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0.38rem 0.5rem;
color: var(--color-text-secondary);
font-size: 0.88rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.footer-total {
margin-right: auto;
color: var(--color-text-secondary);
@@ -1274,11 +1736,21 @@
}
@media (max-width: 1180px) {
/* Drop the shared grid and stack each row as its own card. */
.log,
.log.select-mode {
display: flex;
flex-direction: column;
grid-template-columns: none;
}
.log-head {
display: none;
}
.row {
.row,
.log.select-mode .row {
display: grid;
grid-template-columns: 1fr;
}
@@ -1286,6 +1758,12 @@
display: block;
}
/* Stacked card layout: show the checkbox inline with its label. */
.select-cell {
justify-content: flex-start;
gap: 0.5rem;
}
.row-actions {
justify-content: flex-start;
}
@@ -1333,4 +1811,164 @@
text-align: left;
}
}
/* Unsaved-changes confirm dialog, mirroring the throughput delete dialog. */
.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);
}
.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: var(--color-warning-tint);
color: var(--color-warning-text);
}
.modal-icon.danger {
background: var(--color-error-tint, color-mix(in srgb, var(--color-error) 14%, transparent));
color: var(--color-error);
}
.modal-icon.ok {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success);
}
.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: var(--color-error);
border: 1px solid var(--color-error);
color: #fff;
}
.modal-confirm:hover {
background: color-mix(in srgb, var(--color-error) 85%, black);
}
/* Non-destructive confirm (mark inactive / acknowledge result). */
.modal-confirm.neutral {
background: var(--color-brand);
border-color: var(--color-brand);
color: var(--color-on-brand);
}
.modal-confirm.neutral:hover {
background: color-mix(in srgb, var(--color-brand) 88%, black);
}
.modal-cancel:disabled,
.modal-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-cancel:focus-visible,
.modal-confirm:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
}
/* Per-mix reasons shown in the result modal when some rows were skipped. */
.result-failures {
display: grid;
gap: 0.4rem;
max-height: 11rem;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.result-failures li {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.5rem 0.65rem;
border: 1px solid color-mix(in srgb, var(--color-error) 28%, var(--color-border));
border-radius: 0.55rem;
background: color-mix(in srgb, var(--color-error) 7%, var(--color-bg-surface));
}
.result-failures strong {
font-size: 0.9rem;
font-weight: 700;
color: var(--color-text-primary);
}
.result-failures span {
font-size: 0.82rem;
color: var(--color-text-secondary);
}
</style>
+75 -13
View File
@@ -2,11 +2,13 @@
import { api } from '$lib/api';
import { toast } from '$lib/toast';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import ChangeHistoryModal from '$lib/components/editor/ChangeHistoryModal.svelte';
import CategoryCombobox from '$lib/components/editor/CategoryCombobox.svelte';
import SortHeader from '$lib/table/SortHeader.svelte';
import { TableController } from '$lib/table/table.svelte';
import { formatNumber } from '$lib/format';
import type { EditorIngredientRow } from '$lib/types';
import { ChevronLeft, ChevronRight, FlaskConical, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { ChevronLeft, ChevronRight, FlaskConical, History, ListFilter, Plus, Save, Search, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
let { data } = $props();
@@ -20,6 +22,7 @@
draft_kg_per_unit: number | string;
draft_status: string;
draft_rounding_decimals: number;
draft_category: string;
};
function toEditable(row: EditorIngredientRow): EditableIngredient {
@@ -29,7 +32,8 @@
draft_unit_of_measure: row.unit_of_measure,
draft_kg_per_unit: row.kg_per_unit,
draft_status: row.status,
draft_rounding_decimals: row.rounding_decimals
draft_rounding_decimals: row.rounding_decimals,
draft_category: row.category ?? ''
};
}
@@ -37,6 +41,8 @@
let query = $state('');
let statusFilter = $state<'active' | 'archived' | 'all'>('active');
let savingKey = $state<string | null>(null);
// The ingredient whose change history is open in the modal (null = closed).
let historyIngredient = $state<EditableIngredient | null>(null);
$effect(() => {
if (rows.length === 0 && (data.ingredients as EditorIngredientRow[]).length > 0) {
@@ -54,7 +60,8 @@
row.draft_unit_of_measure.trim() !== row.unit_of_measure ||
Number(row.draft_kg_per_unit) !== row.kg_per_unit ||
row.draft_status !== row.status ||
Number(row.draft_rounding_decimals) !== row.rounding_decimals
Number(row.draft_rounding_decimals) !== row.rounding_decimals ||
row.draft_category.trim() !== (row.category ?? '')
);
}
@@ -88,7 +95,8 @@
unit_of_measure: row.draft_unit_of_measure.trim(),
kg_per_unit: Number(row.draft_kg_per_unit),
status: row.draft_status,
rounding_decimals: Number(row.draft_rounding_decimals)
rounding_decimals: Number(row.draft_rounding_decimals),
category: row.draft_category.trim() || null
})
);
toast.success('Ingredient saved');
@@ -106,7 +114,8 @@
unit_of_measure: '',
kg_per_unit: '' as number | string,
status: 'active',
rounding_decimals: 2
rounding_decimals: 2,
category: ''
};
}
let showNew = $state(false);
@@ -131,7 +140,8 @@
unit_of_measure: newIngredient.unit_of_measure.trim(),
kg_per_unit: Number(newIngredient.kg_per_unit),
status: newIngredient.status,
rounding_decimals: Number(newIngredient.rounding_decimals)
rounding_decimals: Number(newIngredient.rounding_decimals),
category: newIngredient.category.trim() || null
});
rows = [toEditable(created), ...rows];
toast.success('Ingredient added');
@@ -160,12 +170,39 @@
(statusFilter === 'archived' && !isActive(row.status));
if (!statusMatches) return false;
if (!term) return true;
return [row.name, row.unit_of_measure].join(' ').toLowerCase().includes(term);
return [row.name, row.unit_of_measure, row.category ?? ''].join(' ').toLowerCase().includes(term);
})
);
// Categories the user has explicitly created via the combobox this session.
// Tracked separately from row values so a freshly-created category stays
// available to every row even before it's been saved to (or assigned on) any
// ingredient — otherwise it would vanish as soon as you moved off the row.
let createdCategories = $state<string[]>([]);
function registerCategory(category: string) {
const name = category.trim();
if (!name) return;
if (createdCategories.some((existing) => existing.toLowerCase() === name.toLowerCase())) return;
createdCategories = [...createdCategories, name];
}
// Existing categories, offered as autocomplete suggestions so spelling stays
// consistent across ingredients.
const knownCategories = $derived(
Array.from(
new Set(
[
...rows.map((row) => (row.draft_category || row.category || '').trim()),
...createdCategories.map((value) => value.trim())
].filter((value) => value.length > 0)
)
).sort((a, b) => a.localeCompare(b))
);
const table = new TableController<EditableIngredient>(() => visibleRows, {
name: (row) => row.name,
category: (row) => row.category ?? '',
unit_of_measure: (row) => row.unit_of_measure,
kg_per_unit: (row) => row.kg_per_unit,
cost_per_kg: (row) => row.cost_per_kg,
@@ -273,6 +310,10 @@
<span>Kg per unit</span>
<input bind:value={newIngredient.kg_per_unit} type="number" min="0" step="0.0001" placeholder="0" />
</label>
<label>
<span>Category</span>
<CategoryCombobox bind:value={newIngredient.category} options={knownCategories} placeholder="e.g. Grains" inputId="new-ingredient-category" oncreate={registerCategory} />
</label>
<label>
<span>Rounding</span>
<select bind:value={newIngredient.rounding_decimals}>
@@ -322,6 +363,7 @@
<div class="log">
<div class="log-head">
<SortHeader label="Ingredient" column="name" controller={table} />
<SortHeader label="Category" column="category" controller={table} />
<SortHeader label="Unit" column="unit_of_measure" controller={table} />
<SortHeader label="Kg / unit" column="kg_per_unit" controller={table} />
<SortHeader label="Cost / kg" column="cost_per_kg" controller={table} />
@@ -338,6 +380,11 @@
<input bind:value={row.draft_name} aria-label="Ingredient name" />
</div>
<div class="cell">
<span class="cell-label">Category</span>
<CategoryCombobox bind:value={row.draft_category} options={knownCategories} placeholder="—" ariaLabel={`Category for ${row.name}`} oncreate={registerCategory} />
</div>
<div class="cell">
<span class="cell-label">Unit</span>
<input bind:value={row.draft_unit_of_measure} aria-label="Unit of measure" />
@@ -380,6 +427,10 @@
</div>
<div class="row-actions">
<button class="clear-button" type="button" onclick={() => (historyIngredient = row)} aria-label={`History for ${row.name}`}>
<History size={16} strokeWidth={2.2} />
History
</button>
<button class="apply-button" type="button" disabled={!rowDirty(row) || savingKey === `row:${row.id}`} onclick={() => saveRow(row)}>
<Save size={16} strokeWidth={2.4} />
{savingKey === `row:${row.id}` ? 'Saving...' : 'Save'}
@@ -406,6 +457,16 @@
{/each}
</div>
</section>
{#if historyIngredient}
<ChangeHistoryModal
entityType="ingredient"
entityId={historyIngredient.id}
title={historyIngredient.name}
subtitle={historyIngredient.unit_of_measure}
onClose={() => (historyIngredient = null)}
/>
{/if}
</AppSecondaryRailLayout>
<style>
@@ -815,13 +876,14 @@
.row {
display: grid;
grid-template-columns:
minmax(180px, 1.45fr)
minmax(96px, 0.7fr)
minmax(160px, 1.3fr)
minmax(104px, 0.7fr)
minmax(90px, 0.6fr)
minmax(88px, 0.5fr)
minmax(92px, 0.5fr)
minmax(96px, 0.55fr)
minmax(86px, 0.5fr)
minmax(86px, 0.5fr)
minmax(110px, 0.6fr)
minmax(82px, 0.45fr)
minmax(82px, 0.45fr)
minmax(104px, 0.55fr)
minmax(150px, auto);
gap: 0.55rem;
align-items: center;
+318 -3
View File
@@ -2,18 +2,28 @@
import { api } from '$lib/api';
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
import { clientSession } from '$lib/session';
import RoleManagementPanel from '$lib/components/settings/RoleManagementPanel.svelte';
import UserManagementPanel from '$lib/components/settings/UserManagementPanel.svelte';
import { clientSession, hasPermission } from '$lib/session';
import { canEditThroughput } from '$lib/workspace-access';
import { toast } from '$lib/toast';
import type { ThroughputImportResult } from '$lib/types';
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert, Trash2, Users } from 'lucide-svelte';
type Section = 'profile' | 'security' | 'import';
type Section = 'profile' | 'security' | 'import' | 'users' | 'roles';
let activeSection = $state<Section>('profile');
// Only operators who can edit throughput see (and can use) the import tool.
const canImportThroughput = $derived(canEditThroughput($clientSession));
// Lean owners and admins (manage_users permission) get the User management
// section. Visibility is a convenience — every endpoint enforces the
// permission itself.
const canManageUsers = $derived(hasPermission($clientSession, 'manage_users'));
const canManageRoles = $derived(
$clientSession?.role === 'internal' && ['lean', 'admin'].includes($clientSession?.role_name?.toLowerCase() ?? '')
);
let name = $state($clientSession?.name ?? '');
let email = $state($clientSession?.email ?? '');
@@ -117,6 +127,47 @@
}
}
// ── Delete all throughput entries ─────────────────────────────
// A destructive maintenance action — clearing a bad import in one go rather
// than deleting runs one by one. Guarded behind a typed confirmation.
let showDeleteAll = $state(false);
let deleteAllConfirmText = $state('');
let deletingAll = $state(false);
let deleteAllResult = $state<number | null>(null);
function openDeleteAll() {
deleteAllConfirmText = '';
deleteAllResult = null;
showDeleteAll = true;
}
function cancelDeleteAll() {
if (deletingAll) return;
showDeleteAll = false;
deleteAllConfirmText = '';
}
async function confirmDeleteAll() {
if (deleteAllConfirmText.trim().toUpperCase() !== 'DELETE') return;
deletingAll = true;
const tid = toast.loading('Deleting all entries…');
try {
const result = await api.deleteAllThroughputEntries();
deleteAllResult = result.entries_deleted;
showDeleteAll = false;
deleteAllConfirmText = '';
toast.dismiss(tid);
toast.success(
`Deleted ${result.entries_deleted} ${result.entries_deleted === 1 ? 'entry' : 'entries'}`
);
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to delete entries');
} finally {
deletingAll = false;
}
}
// Build a small sample CSV in the browser so operators have a working header
// row to copy from. The backend matches these headers case-insensitively.
function downloadTemplate() {
@@ -171,6 +222,8 @@
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole },
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
...(canManageUsers ? [{ id: 'users' as Section, label: 'Users', icon: Users }] : []),
...(canManageRoles ? [{ id: 'roles' as Section, label: 'Roles', icon: Users }] : []),
]);
const railGroups = $derived([{ items: navItems }]);
@@ -339,11 +392,81 @@
</div>
</div>
</div>
<div class="danger-zone">
<div class="danger-copy">
<h3><TriangleAlert size={16} strokeWidth={2.2} /> Delete all entries</h3>
<p>
Permanently remove every throughput entry. Products are kept, but all
packing runs are erased. This cannot be undone — use it to clear a bad
import before re-uploading.
</p>
{#if deleteAllResult !== null}
<p class="danger-result" role="status">
Deleted <strong>{deleteAllResult}</strong>
{deleteAllResult === 1 ? 'entry' : 'entries'}.
</p>
{/if}
</div>
<button type="button" class="btn-danger" onclick={openDeleteAll}>
<Trash2 size={15} strokeWidth={2.2} /> Delete all entries
</button>
</div>
</div>
{:else if activeSection === 'users' && canManageUsers}
<UserManagementPanel />
{:else if activeSection === 'roles' && canManageRoles}
<RoleManagementPanel />
{/if}
</div>
</AppSecondaryRailLayout>
{#if showDeleteAll}
<div class="modal-backdrop" role="presentation" onclick={cancelDeleteAll}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-all-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') cancelDeleteAll(); }}
>
<div class="modal-icon"><Trash2 size={22} strokeWidth={2.2} /></div>
<h2 id="delete-all-title" class="modal-title">Delete all throughput entries?</h2>
<p class="modal-text">
This permanently removes <strong>every</strong> throughput entry for your
workspace. Products are kept, but the packing-run history cannot be recovered.
</p>
<label class="modal-confirm-field">
<span>Type <strong>DELETE</strong> to confirm</span>
<input
type="text"
bind:value={deleteAllConfirmText}
autocomplete="off"
spellcheck="false"
placeholder="DELETE"
/>
</label>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={cancelDeleteAll} disabled={deletingAll}>
Cancel
</button>
<button
type="button"
class="modal-confirm"
disabled={deletingAll || deleteAllConfirmText.trim().toUpperCase() !== 'DELETE'}
onclick={confirmDeleteAll}
>
{deletingAll ? 'Deleting…' : 'Delete all entries'}
</button>
</div>
</div>
</div>
{/if}
<style>
.settings-panel {
display: flex;
@@ -622,6 +745,193 @@
overflow-y: auto;
}
/* ── Danger zone ────────────────────────────────────────────── */
.danger-zone {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.25rem;
margin: 0 1.75rem 1.75rem;
padding: 1.1rem 1.25rem;
border: 1px solid color-mix(in srgb, #e53e3e 30%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, #e53e3e 5%, transparent);
}
.danger-copy {
min-width: 0;
}
.danger-copy h3 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.35rem;
font-size: 0.92rem;
font-weight: 700;
color: #c53030;
}
.danger-copy p {
margin: 0;
font-size: 0.83rem;
line-height: 1.5;
color: var(--muted);
max-width: 34rem;
}
.danger-result {
margin-top: 0.5rem !important;
color: var(--text) !important;
}
.btn-danger {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.58rem 1.1rem;
background: #b3261e;
color: #fff;
border: 1px solid #b3261e;
border-radius: 0.6rem;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
transition: background-color 140ms ease;
}
.btn-danger:hover {
background: #95201a;
}
/* ── Confirmation 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(28rem, 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.8rem;
height: 2.8rem;
border-radius: 0.8rem;
background: #fdecee;
color: #b3261e;
}
.modal-title {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.modal-text {
margin: 0;
font-size: 0.92rem;
line-height: 1.5;
color: var(--muted);
}
.modal-confirm-field {
display: grid;
gap: 0.4rem;
margin-top: 0.2rem;
}
.modal-confirm-field span {
font-size: 0.82rem;
color: var(--muted);
}
.modal-confirm-field input {
width: 100%;
padding: 0.55rem 0.8rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
letter-spacing: 0.06em;
box-sizing: border-box;
}
.modal-confirm-field input:focus {
outline: none;
border-color: #b3261e;
box-shadow: 0 0 0 3px color-mix(in srgb, #b3261e 18%, transparent);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.55rem;
}
.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, border-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;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) {
@@ -632,5 +942,10 @@
.import-body {
grid-template-columns: 1fr;
}
.danger-zone {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+7 -36
View File
@@ -12,13 +12,11 @@
import ThroughputSuccessOverlay from '$lib/components/throughput/ThroughputSuccessOverlay.svelte';
import ThroughputSummary from '$lib/components/throughput/ThroughputSummary.svelte';
import {
MIX_RANGES,
addDays,
ausToday,
buildConfetti,
compareDate,
compareText,
isStockEntry,
startOfWeekMonday,
toISODate,
type SortDirection,
@@ -71,24 +69,6 @@
statsEntries = statsEntries.filter((e) => e.id !== id);
}
// The destination shown in the log: an order (with job number), stock, or a
// split across both.
function destinationOf(entry: ThroughputEntry): { label: string; detail: string | null } {
const unit = entry.quantity_type === 'bags' ? 'bags' : 'kg';
if (entry.for_order && entry.for_stock) {
const stock = entry.stock_quantity != null ? `${formatNumber(entry.stock_quantity, 1)} ${unit} to stock` : 'split';
const job = entry.job_number ? `Order ${entry.job_number}` : 'Order';
return { label: 'Split', detail: `${job} · ${stock}` };
}
if (entry.for_order) {
return { label: 'Order', detail: entry.job_number ? `Job ${entry.job_number}` : null };
}
if (isStockEntry(entry)) {
return { label: 'Stock', detail: null };
}
return { label: '—', detail: null };
}
// ── Inline "spreadsheet" add row ──────────────────────────────
const today = toISODate(ausToday());
let nDate = $state(today);
@@ -256,11 +236,10 @@
return;
}
if (!nForOrder && !nForStock) {
addError = 'Mark where this run goes: for an order, for stock, or both.';
return;
}
// The order/stock destination split was removed from the composer (operators
// found it hard and it wasn't being used). New runs are saved without a
// destination; the guards below only fire when editing legacy entries that
// still carry order/stock flags.
const job = nJobNumber.trim();
if (nForOrder && !job) {
addError = 'Enter the job number for the order.';
@@ -431,15 +410,13 @@
return norm.includes('phf') && norm.includes('horse');
}
let mixRangeKey = $state<(typeof MIX_RANGES)[number]['key']>('4w');
const mixRange = $derived(MIX_RANGES.find((r) => r.key === mixRangeKey) ?? MIX_RANGES[1]);
// Today's split only: Horse Mix (PHF Horsemix) vs Grain Mix (everyone else).
const mixTotals = $derived.by(() => {
const cutoff = toISODate(addDays(ausToday(), -(mixRange.days - 1)));
const todayStr = toISODate(ausToday());
let horse = 0;
let grain = 0;
for (const entry of statsEntries) {
if (entry.production_date < cutoff) continue;
if (entry.production_date !== todayStr) continue;
const kg = entry.calculated_kg || 0;
const client = entry.product_id != null ? productClientById.get(entry.product_id) ?? null : null;
if (isHorseMixClient(client)) horse += kg;
@@ -499,10 +476,6 @@
result = (a.calculated_kg ?? 0) - (b.calculated_kg ?? 0);
} else if (sortKey === 'staff') {
result = compareText(a.staff_name, b.staff_name);
} else if (sortKey === 'destination') {
const aDest = destinationOf(a);
const bDest = destinationOf(b);
result = compareText(`${aDest.label} ${aDest.detail ?? ''}`, `${bDest.label} ${bDest.detail ?? ''}`);
} else if (sortKey === 'notes') {
result = compareText(a.notes, b.notes);
}
@@ -531,7 +504,6 @@
{weekRangeLabel}
{heroStats}
{mixTotals}
bind:mixRangeKey
{formatDate}
{formatNumber}
/>
@@ -587,7 +559,6 @@
{formatNumber}
{packedMain}
{packedDetail}
{destinationOf}
onApplyFilters={applyFilters}
onClearFilters={clearFilters}
onToggleSort={toggleSort}