Compare commits
10
Commits
7db95e2027
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4d9d77e5 | ||
|
|
dc50e0538e | ||
|
|
c9f233dc0e | ||
|
|
87878e70fc | ||
|
|
696f1e7b09 | ||
|
|
10722a65a6 | ||
|
|
1062c038e8 | ||
|
|
e7a7b11589 | ||
|
|
1dd48bc771 | ||
|
|
3f8279af10 |
+547
-19
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.access import (
|
||||
@@ -65,6 +65,9 @@ class RoleRead(BaseModel):
|
||||
name: str
|
||||
description: str | None
|
||||
permissions: list[str]
|
||||
module_permissions: dict[str, str]
|
||||
is_protected: bool = False
|
||||
user_count: int = 0
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
@@ -73,6 +76,273 @@ class UserRead(BaseModel):
|
||||
name: str
|
||||
is_active: bool
|
||||
role: str | None
|
||||
role_id: int | None
|
||||
# True when this user can never be deleted (lean owner accounts). The UI
|
||||
# uses this to disable the delete control rather than re-deriving the rule.
|
||||
is_protected: bool = False
|
||||
|
||||
|
||||
class AssignableRole(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
|
||||
class RoleModuleDefinition(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
levels: list[str]
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
email: str
|
||||
name: str
|
||||
role_id: int | None = None
|
||||
is_active: bool = True
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class AdminUpdateUserRequest(BaseModel):
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
role_id: int | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AdminSetPasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class CreateRoleRequest(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
module_permissions: dict[str, str] = {}
|
||||
|
||||
|
||||
class UpdateRoleRequest(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
module_permissions: dict[str, str] | None = None
|
||||
|
||||
|
||||
# Lean owner accounts are permanent: they may be edited but never deleted, so a
|
||||
# tenant can't accidentally lock itself out of the highest level of access.
|
||||
LEAN_ROLE_NAME = "lean"
|
||||
ADMIN_ROLE_NAME = "admin"
|
||||
ROLE_MANAGEMENT_ALLOWED_ROLES = {LEAN_ROLE_NAME, ADMIN_ROLE_NAME}
|
||||
PROTECTED_ROLE_NAMES = ROLE_MANAGEMENT_ALLOWED_ROLES
|
||||
|
||||
ROLE_MODULE_DEFINITIONS: tuple[dict[str, object], ...] = (
|
||||
{
|
||||
"key": "dashboard",
|
||||
"label": "Dashboard",
|
||||
"description": "Home dashboard visibility.",
|
||||
"levels": {"none": (), "view": ("view_dashboard",)},
|
||||
},
|
||||
{
|
||||
"key": "mix_calculator",
|
||||
"label": "Mix Calculator",
|
||||
"description": "Open the calculator and save sessions.",
|
||||
"levels": {
|
||||
"none": (),
|
||||
"view": ("view_mix_calculator",),
|
||||
"edit": ("view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "raw_materials",
|
||||
"label": "Raw Materials",
|
||||
"description": "View or edit raw materials.",
|
||||
"levels": {"none": (), "view": ("view_raw_materials",), "edit": ("view_raw_materials", "edit_raw_materials")},
|
||||
},
|
||||
{
|
||||
"key": "products",
|
||||
"label": "Products",
|
||||
"description": "View or edit finished products.",
|
||||
"levels": {"none": (), "view": ("view_products",), "edit": ("view_products", "edit_products")},
|
||||
},
|
||||
{
|
||||
"key": "mix_master",
|
||||
"label": "Mix Master",
|
||||
"description": "View or edit mix recipes.",
|
||||
"levels": {"none": (), "view": ("view_mixes",), "edit": ("view_mixes", "edit_mixes")},
|
||||
},
|
||||
{
|
||||
"key": "operations_throughput",
|
||||
"label": "Throughput",
|
||||
"description": "View or edit throughput entries.",
|
||||
"levels": {"none": (), "view": ("view_throughput",), "edit": ("view_throughput", "edit_throughput")},
|
||||
},
|
||||
{
|
||||
"key": "ordering",
|
||||
"label": "Ordering",
|
||||
"description": "Access customer ordering and ordering administration.",
|
||||
"levels": {
|
||||
"none": (),
|
||||
"view": ("view_ordering",),
|
||||
"edit": ("view_ordering", "edit_ordering"),
|
||||
"manage": ("view_ordering", "edit_ordering", "manage_ordering"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "scenarios",
|
||||
"label": "Scenarios",
|
||||
"description": "View or run scenarios.",
|
||||
"levels": {"none": (), "view": ("view_scenarios",), "edit": ("view_scenarios", "edit_scenarios")},
|
||||
},
|
||||
{
|
||||
"key": "client_access",
|
||||
"label": "Client Access",
|
||||
"description": "Manage customer portal accounts and access.",
|
||||
"levels": {"none": (), "manage": ("manage_client_access",)},
|
||||
},
|
||||
{
|
||||
"key": "users",
|
||||
"label": "Users",
|
||||
"description": "View or manage internal users.",
|
||||
"levels": {"none": (), "view": ("view_users",), "manage": ("view_users", "manage_users")},
|
||||
},
|
||||
{
|
||||
"key": "roles",
|
||||
"label": "Roles",
|
||||
"description": "Manage roles and permission assignments.",
|
||||
"levels": {"none": (), "manage": ("manage_permissions",)},
|
||||
},
|
||||
{
|
||||
"key": "settings",
|
||||
"label": "Settings",
|
||||
"description": "Open settings and edit system configuration.",
|
||||
"levels": {"none": (), "view": ("view_settings",), "edit": ("view_settings", "edit_settings")},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _serialize_user_read(user: User) -> UserRead:
|
||||
role_name = user.role.name if user.role else None
|
||||
return UserRead(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
is_active=user.is_active,
|
||||
role=role_name,
|
||||
role_id=user.role_id,
|
||||
is_protected=(role_name or "").lower() == LEAN_ROLE_NAME,
|
||||
)
|
||||
|
||||
|
||||
def _role_name_lower(role: Role | None) -> str:
|
||||
return (role.name if role else "").strip().lower()
|
||||
|
||||
|
||||
def _is_protected_role_name(role_name: str | None) -> bool:
|
||||
return (role_name or "").strip().lower() in PROTECTED_ROLE_NAMES
|
||||
|
||||
|
||||
def _module_definitions_response() -> list[RoleModuleDefinition]:
|
||||
return [
|
||||
RoleModuleDefinition(
|
||||
key=definition["key"],
|
||||
label=definition["label"],
|
||||
description=definition["description"],
|
||||
levels=list(definition["levels"].keys()),
|
||||
)
|
||||
for definition in ROLE_MODULE_DEFINITIONS
|
||||
]
|
||||
|
||||
|
||||
def _permissions_to_role_module_map(permission_keys: set[str]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for definition in ROLE_MODULE_DEFINITIONS:
|
||||
selected = "none"
|
||||
levels = definition["levels"]
|
||||
for level, required in levels.items():
|
||||
required_keys = set(required)
|
||||
if not required_keys or required_keys.issubset(permission_keys):
|
||||
selected = level
|
||||
result[definition["key"]] = selected
|
||||
return result
|
||||
|
||||
|
||||
def _role_payload_to_permission_keys(module_permissions: dict[str, str]) -> set[str]:
|
||||
known_modules = {definition["key"] for definition in ROLE_MODULE_DEFINITIONS}
|
||||
unknown_modules = sorted(set(module_permissions) - known_modules)
|
||||
if unknown_modules:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Unknown modules: {unknown_modules}",
|
||||
)
|
||||
|
||||
granted: set[str] = set()
|
||||
for definition in ROLE_MODULE_DEFINITIONS:
|
||||
key = definition["key"]
|
||||
level = module_permissions.get(key, "none")
|
||||
available_levels: dict[str, tuple[str, ...]] = definition["levels"] # type: ignore[assignment]
|
||||
if level not in available_levels:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid access level '{level}' for module '{key}'",
|
||||
)
|
||||
granted.update(available_levels[level])
|
||||
return granted
|
||||
|
||||
|
||||
def _serialize_role_read(role: Role, *, user_count: int = 0) -> RoleRead:
|
||||
permission_keys = {permission.key for permission in role.permissions}
|
||||
return RoleRead(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
permissions=sorted(permission_keys),
|
||||
module_permissions=_permissions_to_role_module_map(permission_keys),
|
||||
is_protected=_is_protected_role_name(role.name),
|
||||
user_count=user_count,
|
||||
)
|
||||
|
||||
|
||||
def _require_role_management_actor(user: User = Depends(get_current_user)) -> User:
|
||||
if _role_name_lower(user.role) not in ROLE_MANAGEMENT_ALLOWED_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only lean and admin accounts can manage roles",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _load_role(db: Session, role_id: int) -> Role:
|
||||
role = db.scalar(
|
||||
select(Role).where(Role.id == role_id).options(selectinload(Role.permissions))
|
||||
)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
|
||||
return role
|
||||
|
||||
|
||||
def _apply_role_updates(
|
||||
role: Role,
|
||||
*,
|
||||
name: str | None,
|
||||
description: str | None,
|
||||
module_permissions: dict[str, str] | None,
|
||||
permissions_by_key: dict[str, Permission],
|
||||
) -> None:
|
||||
if name is not None:
|
||||
trimmed_name = name.strip()
|
||||
if not trimmed_name:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
|
||||
role.name = trimmed_name
|
||||
|
||||
if description is not None:
|
||||
role.description = description.strip() or None
|
||||
|
||||
if module_permissions is not None:
|
||||
desired_permission_keys = _role_payload_to_permission_keys(module_permissions)
|
||||
desired = {permissions_by_key[key] for key in desired_permission_keys}
|
||||
current = set(role.permissions)
|
||||
for permission in desired - current:
|
||||
role.permissions.append(permission)
|
||||
for permission in current - desired:
|
||||
role.permissions.remove(permission)
|
||||
|
||||
|
||||
def _serialize_session(user: User, *, include_token: bool = False) -> UserSession:
|
||||
@@ -216,36 +486,294 @@ def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_permission("view_users")), # gated by permission key
|
||||
):
|
||||
users = db.scalars(select(User).options(selectinload(User.role))).all()
|
||||
users = db.scalars(
|
||||
select(User).options(selectinload(User.role)).order_by(User.name)
|
||||
).all()
|
||||
return [_serialize_user_read(user) for user in users]
|
||||
|
||||
|
||||
@router.get("/assignable-roles", response_model=list[AssignableRole])
|
||||
def list_assignable_roles(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Roles that a user-manager can assign — used to populate the role picker.
|
||||
|
||||
Separate from ``/roles`` (which exposes full permission sets and is gated by
|
||||
``manage_permissions``); managing users only needs the role list itself.
|
||||
"""
|
||||
roles = db.scalars(select(Role).order_by(Role.name)).all()
|
||||
return [
|
||||
UserRead(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
is_active=user.is_active,
|
||||
role=user.role.name if user.role else None,
|
||||
)
|
||||
for user in users
|
||||
AssignableRole(id=role.id, name=role.name, description=role.description)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _load_managed_user(db: Session, user_id: int) -> User:
|
||||
user = db.scalar(
|
||||
select(User).where(User.id == user_id).options(selectinload(User.role))
|
||||
)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def _resolve_role(db: Session, role_id: int | None) -> Role | None:
|
||||
if role_id is None:
|
||||
return None
|
||||
role = db.scalar(select(Role).where(Role.id == role_id))
|
||||
if role is None:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unknown role")
|
||||
return role
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
payload: CreateUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Create a new internal user.
|
||||
|
||||
A user with no password can still sign in with the shared internal password
|
||||
until they set a personal one in their own settings.
|
||||
"""
|
||||
email = payload.email.strip().lower()
|
||||
if not email or "@" not in email:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
|
||||
if db.scalar(select(User).where(User.email == email)):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
|
||||
|
||||
role = _resolve_role(db, payload.role_id)
|
||||
|
||||
password_hash = None
|
||||
if payload.password:
|
||||
if len(payload.password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Password must be at least 8 characters",
|
||||
)
|
||||
password_hash = hash_password(payload.password)
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
name=name,
|
||||
role_id=role.id if role else None,
|
||||
is_active=payload.is_active,
|
||||
password_hash=password_hash,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_security_event("users.created", audience="internal", actor_user_id=actor.id, user_id=user.id)
|
||||
return _serialize_user_read(user)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserRead)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
payload: AdminUpdateUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Update another user's name, email, role, or active status."""
|
||||
user = _load_managed_user(db, user_id)
|
||||
|
||||
if payload.is_active is not None:
|
||||
# Guard against locking yourself out of your own management session.
|
||||
if user.id == actor.id and not payload.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot deactivate your own account",
|
||||
)
|
||||
user.is_active = payload.is_active
|
||||
|
||||
if payload.name is not None:
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
|
||||
user.name = name
|
||||
|
||||
if payload.email is not None:
|
||||
email = payload.email.strip().lower()
|
||||
if not email or "@" not in email:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
|
||||
existing = db.scalar(select(User).where(User.email == email, User.id != user.id))
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
|
||||
user.email = email
|
||||
|
||||
if payload.role_id is not None:
|
||||
role = _resolve_role(db, payload.role_id)
|
||||
user.role_id = role.id if role else None
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_security_event("users.updated", audience="internal", actor_user_id=actor.id, user_id=user.id)
|
||||
return _serialize_user_read(user)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password", response_model=UserRead)
|
||||
def set_user_password(
|
||||
user_id: int,
|
||||
payload: AdminSetPasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Set (reset) another user's password without their current password."""
|
||||
user = _load_managed_user(db, user_id)
|
||||
if len(payload.new_password) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="New password must be at least 8 characters",
|
||||
)
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
log_security_event("users.password_reset", audience="internal", actor_user_id=actor.id, user_id=user.id)
|
||||
return _serialize_user_read(user)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(require_permission("manage_users")), # gated by permission key
|
||||
):
|
||||
"""Delete a user. Lean owner accounts and your own account are protected."""
|
||||
user = _load_managed_user(db, user_id)
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot delete your own account",
|
||||
)
|
||||
if (user.role.name if user.role else "").lower() == LEAN_ROLE_NAME:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Lean accounts cannot be deleted",
|
||||
)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
log_security_event("users.deleted", audience="internal", actor_user_id=actor.id, user_id=user_id)
|
||||
response.status_code = status.HTTP_204_NO_CONTENT
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/roles", response_model=list[RoleRead])
|
||||
def list_roles(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_permission("manage_permissions")), # gated by permission key
|
||||
_: User = Depends(_require_role_management_actor),
|
||||
):
|
||||
user_counts = dict(
|
||||
db.execute(select(User.role_id, func.count(User.id)).group_by(User.role_id)).all()
|
||||
)
|
||||
roles = db.scalars(
|
||||
select(Role).options(selectinload(Role.permissions)).order_by(Role.name)
|
||||
).all()
|
||||
return [
|
||||
RoleRead(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
permissions=sorted(p.key for p in role.permissions),
|
||||
return [_serialize_role_read(role, user_count=user_counts.get(role.id, 0)) for role in roles]
|
||||
|
||||
|
||||
@router.get("/role-modules", response_model=list[RoleModuleDefinition])
|
||||
def list_role_modules(_: User = Depends(_require_role_management_actor)):
|
||||
return _module_definitions_response()
|
||||
|
||||
|
||||
@router.post("/roles", response_model=RoleRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_role(
|
||||
payload: CreateRoleRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(_require_role_management_actor),
|
||||
):
|
||||
name = payload.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Role name cannot be empty")
|
||||
existing = db.scalar(select(Role).where(func.lower(Role.name) == name.lower()))
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
|
||||
|
||||
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
|
||||
role = Role(name=name, description=None)
|
||||
db.add(role)
|
||||
db.flush()
|
||||
_apply_role_updates(
|
||||
role,
|
||||
name=name,
|
||||
description=payload.description,
|
||||
module_permissions=payload.module_permissions,
|
||||
permissions_by_key=permissions_by_key,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
log_security_event("roles.created", audience="internal", actor_user_id=actor.id, role_id=role.id)
|
||||
return _serialize_role_read(role, user_count=0)
|
||||
|
||||
|
||||
@router.patch("/roles/{role_id}", response_model=RoleRead)
|
||||
def update_role(
|
||||
role_id: int,
|
||||
payload: UpdateRoleRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(_require_role_management_actor),
|
||||
):
|
||||
role = _load_role(db, role_id)
|
||||
original_name = role.name
|
||||
protected = _is_protected_role_name(original_name)
|
||||
requested_name = payload.name.strip() if payload.name is not None else role.name
|
||||
if protected and requested_name.lower() != original_name.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Lean and admin roles cannot be renamed",
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
if payload.name is not None:
|
||||
duplicate = db.scalar(
|
||||
select(Role).where(func.lower(Role.name) == requested_name.lower(), Role.id != role.id)
|
||||
)
|
||||
if duplicate:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Role name already exists")
|
||||
|
||||
permissions_by_key = {permission.key: permission for permission in db.scalars(select(Permission)).all()}
|
||||
_apply_role_updates(
|
||||
role,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
module_permissions=payload.module_permissions,
|
||||
permissions_by_key=permissions_by_key,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
user_count = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
|
||||
log_security_event("roles.updated", audience="internal", actor_user_id=actor.id, role_id=role.id)
|
||||
return _serialize_role_read(role, user_count=user_count)
|
||||
|
||||
|
||||
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_role(
|
||||
role_id: int,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
actor: User = Depends(_require_role_management_actor),
|
||||
):
|
||||
role = _load_role(db, role_id)
|
||||
if _is_protected_role_name(role.name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Lean and admin roles cannot be deleted",
|
||||
)
|
||||
assigned_users = db.scalar(select(func.count(User.id)).where(User.role_id == role.id)) or 0
|
||||
if assigned_users > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Reassign users before deleting this role",
|
||||
)
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
log_security_event("roles.deleted", audience="internal", actor_user_id=actor.id, role_id=role_id)
|
||||
response.status_code = status.HTTP_204_NO_CONTENT
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/permissions", response_model=list[str])
|
||||
|
||||
+289
-11
@@ -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:
|
||||
for product in db.scalars(
|
||||
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
||||
).all():
|
||||
product.visible = visible
|
||||
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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.models.access import Permission, Role, User, role_permissions
|
||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||
from app.models.change_event import EditorChangeEvent
|
||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
||||
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
@@ -40,6 +41,7 @@ __all__ = [
|
||||
"ClientUser",
|
||||
"ClientUserModulePermission",
|
||||
"CostingResult",
|
||||
"EditorChangeEvent",
|
||||
"CustomerPriceAssignment",
|
||||
"CustomerProductPrice",
|
||||
"CustomerProductVisibility",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class EditorChangeEvent(Base):
|
||||
"""An audit row recording an edit to a mix or an ingredient.
|
||||
|
||||
Written by the editor API whenever a mix or raw material (ingredient) is
|
||||
created or changed, and read back per-entity by the History buttons on the
|
||||
Mix Editor and Ingredients Editor. `changes` holds a list of
|
||||
``{"field", "label", "before", "after"}`` field deltas so the UI can show a
|
||||
readable before/after for each edit.
|
||||
"""
|
||||
|
||||
__tablename__ = "editor_change_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
# "mix" or "ingredient" — the surface the History button lives on.
|
||||
entity_type: Mapped[str] = mapped_column(String(32), index=True)
|
||||
entity_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
action: Mapped[str] = mapped_column(String(48))
|
||||
actor_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
actor_email: Mapped[str] = mapped_column(String(255), default="")
|
||||
actor_role: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
summary: Mapped[str] = mapped_column(Text, default="")
|
||||
changes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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,31 +234,34 @@ 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)
|
||||
if bag_warning:
|
||||
warnings.append(bag_warning)
|
||||
# A bag warning only makes sense when the unit resolves to a bag size; a
|
||||
# formula-only mix sells in bulk kg, so there's nothing to round to whole bags.
|
||||
if unit_size_kg > 0:
|
||||
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, unit_of_measure)
|
||||
if bag_warning:
|
||||
warnings.append(bag_warning)
|
||||
|
||||
lines = []
|
||||
for index, ingredient in enumerate(formula_rows, start=1):
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -347,3 +347,207 @@ def test_internal_user_can_change_own_password(access_app_and_db):
|
||||
json={"email": admin.email, "password": "new-personal-password"},
|
||||
)
|
||||
assert new_login.status_code == 200
|
||||
|
||||
|
||||
# --- Admin user management --------------------------------------------------
|
||||
|
||||
|
||||
def _admin_headers(db: Session) -> dict[str, str]:
|
||||
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||
return {"Authorization": f"Bearer {_token_for(admin)}"}
|
||||
|
||||
|
||||
def test_manage_users_create_update_password_delete(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
full_access_role = db.query(Role).filter_by(name="Full Access").one()
|
||||
|
||||
created = client.post(
|
||||
"/api/access/users",
|
||||
json={"email": "new.user@hunterstockfeeds.com", "name": "New User", "role_id": full_access_role.id},
|
||||
headers=headers,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["email"] == "new.user@hunterstockfeeds.com"
|
||||
assert body["role"] == "Full Access"
|
||||
assert body["is_protected"] is False
|
||||
user_id = body["id"]
|
||||
|
||||
operations_role = db.query(Role).filter_by(name="Operations").one()
|
||||
updated = client.patch(
|
||||
f"/api/access/users/{user_id}",
|
||||
json={"name": "Renamed", "role_id": operations_role.id, "is_active": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["name"] == "Renamed"
|
||||
assert updated.json()["role"] == "Operations"
|
||||
assert updated.json()["is_active"] is False
|
||||
|
||||
pw = client.post(
|
||||
f"/api/access/users/{user_id}/password",
|
||||
json={"new_password": "brand-new-pass"},
|
||||
headers=headers,
|
||||
)
|
||||
assert pw.status_code == 200
|
||||
db.expire_all()
|
||||
target = db.query(User).filter_by(id=user_id).one()
|
||||
assert verify_password("brand-new-pass", target.password_hash)
|
||||
|
||||
deleted = client.delete(f"/api/access/users/{user_id}", headers=headers)
|
||||
assert deleted.status_code == 204
|
||||
assert db.query(User).filter_by(id=user_id).one_or_none() is None
|
||||
|
||||
|
||||
def test_create_user_rejects_duplicate_email(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
|
||||
response = client.post(
|
||||
"/api/access/users",
|
||||
json={"email": "admin@hunterstockfeeds.com", "name": "Dup"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_manage_users_requires_permission(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
||||
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
|
||||
|
||||
response = client.post(
|
||||
"/api/access/users",
|
||||
json={"email": "x@hunterstockfeeds.com", "name": "X"},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_cannot_deactivate_or_delete_self(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
|
||||
headers = {"Authorization": f"Bearer {_token_for(admin)}"}
|
||||
|
||||
deactivate = client.patch(
|
||||
f"/api/access/users/{admin.id}", json={"is_active": False}, headers=headers
|
||||
)
|
||||
assert deactivate.status_code == 400
|
||||
|
||||
delete = client.delete(f"/api/access/users/{admin.id}", headers=headers)
|
||||
assert delete.status_code == 400
|
||||
|
||||
|
||||
def test_lean_users_cannot_be_deleted(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
lean_role = db.query(Role).filter_by(name="lean").one()
|
||||
lean_user = User(email="owner@hunterstockfeeds.com", name="Owner", role_id=lean_role.id, is_active=True)
|
||||
db.add(lean_user)
|
||||
db.commit()
|
||||
|
||||
listed = client.get("/api/access/users", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
owner_row = next(row for row in listed.json() if row["id"] == lean_user.id)
|
||||
assert owner_row["is_protected"] is True
|
||||
|
||||
response = client.delete(f"/api/access/users/{lean_user.id}", headers=headers)
|
||||
assert response.status_code == 403
|
||||
assert db.query(User).filter_by(id=lean_user.id).one_or_none() is not None
|
||||
|
||||
|
||||
def test_assignable_roles_lists_all_roles(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
|
||||
response = client.get("/api/access/assignable-roles", headers=headers)
|
||||
assert response.status_code == 200
|
||||
names = {row["name"] for row in response.json()}
|
||||
assert names == set(ROLE_DEFINITIONS.keys())
|
||||
|
||||
|
||||
def test_role_management_lists_modules_and_roles_for_admin(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
|
||||
modules = client.get("/api/access/role-modules", headers=headers)
|
||||
assert modules.status_code == 200
|
||||
module_keys = {row["key"] for row in modules.json()}
|
||||
assert {"dashboard", "mix_calculator", "roles", "settings"} <= module_keys
|
||||
|
||||
roles = client.get("/api/access/roles", headers=headers)
|
||||
assert roles.status_code == 200
|
||||
admin_role = next(row for row in roles.json() if row["name"] == "Admin")
|
||||
assert admin_role["is_protected"] is True
|
||||
assert admin_role["module_permissions"]["ordering"] == "manage"
|
||||
assert admin_role["module_permissions"]["roles"] == "manage"
|
||||
|
||||
|
||||
def test_role_management_is_blocked_for_non_admin_non_lean_roles(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
||||
headers = {"Authorization": f"Bearer {_token_for(ops)}"}
|
||||
|
||||
response = client.get("/api/access/roles", headers=headers)
|
||||
assert response.status_code == 403
|
||||
assert "lean and admin" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_role_management_create_update_delete_custom_role(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
|
||||
created = client.post(
|
||||
"/api/access/roles",
|
||||
json={
|
||||
"name": "Reporting Viewer",
|
||||
"description": "Can review dashboards and reporting inputs",
|
||||
"module_permissions": {
|
||||
"dashboard": "view",
|
||||
"products": "view",
|
||||
"settings": "view",
|
||||
},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
created_body = created.json()
|
||||
assert created_body["module_permissions"]["dashboard"] == "view"
|
||||
assert created_body["module_permissions"]["products"] == "view"
|
||||
assert created_body["module_permissions"]["settings"] == "view"
|
||||
role_id = created_body["id"]
|
||||
|
||||
updated = client.patch(
|
||||
f"/api/access/roles/{role_id}",
|
||||
json={
|
||||
"description": "Can review and edit product data",
|
||||
"module_permissions": {
|
||||
"dashboard": "view",
|
||||
"products": "edit",
|
||||
"settings": "view",
|
||||
},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
updated_body = updated.json()
|
||||
assert updated_body["module_permissions"]["products"] == "edit"
|
||||
assert "edit_products" in updated_body["permissions"]
|
||||
|
||||
deleted = client.delete(f"/api/access/roles/{role_id}", headers=headers)
|
||||
assert deleted.status_code == 204
|
||||
assert db.query(Role).filter_by(id=role_id).one_or_none() is None
|
||||
|
||||
|
||||
def test_protected_or_assigned_roles_cannot_be_deleted(access_app_and_db):
|
||||
client, db = access_app_and_db
|
||||
headers = _admin_headers(db)
|
||||
admin_role = db.query(Role).filter_by(name="Admin").one()
|
||||
|
||||
protected = client.delete(f"/api/access/roles/{admin_role.id}", headers=headers)
|
||||
assert protected.status_code == 403
|
||||
|
||||
full_access_role = db.query(Role).filter_by(name="Full Access").one()
|
||||
assigned = client.delete(f"/api/access/roles/{full_access_role.id}", headers=headers)
|
||||
assert assigned.status_code == 400
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""The change log records who edited a mix/ingredient and what changed.
|
||||
|
||||
Covers `record_change` / `diff_fields` / `list_changes`: edits are stored with a
|
||||
field-level before/after diff and read back newest-first per entity.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.db.session import Base
|
||||
from app.services.change_log import (
|
||||
ENTITY_INGREDIENT,
|
||||
ENTITY_MIX,
|
||||
diff_fields,
|
||||
list_changes,
|
||||
record_change,
|
||||
)
|
||||
|
||||
TENANT = "hunter-premium-produce"
|
||||
|
||||
LABELS = {"name": "Name", "kg_per_unit": "Kg per unit", "status": "Status"}
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)()
|
||||
|
||||
|
||||
def _actor() -> AuthSession:
|
||||
return AuthSession(role="internal", email="lara@hunter.test", name="Lara", tenant_id=TENANT, client_role="admin")
|
||||
|
||||
|
||||
def test_diff_fields_only_emits_changed_keys():
|
||||
deltas = diff_fields({"name": "Maize", "kg_per_unit": 25.0}, {"name": "Maize", "kg_per_unit": 30.0}, LABELS)
|
||||
assert len(deltas) == 1
|
||||
assert deltas[0]["field"] == "kg_per_unit"
|
||||
assert deltas[0]["before"] == "25"
|
||||
assert deltas[0]["after"] == "30"
|
||||
|
||||
|
||||
def test_records_and_lists_changes_newest_first():
|
||||
db = _session()
|
||||
session = _actor()
|
||||
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_INGREDIENT,
|
||||
entity_id=7,
|
||||
action="created",
|
||||
summary="Created ingredient “Maize”",
|
||||
)
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_INGREDIENT,
|
||||
entity_id=7,
|
||||
action="updated",
|
||||
summary="Updated Kg per unit",
|
||||
changes=diff_fields({"kg_per_unit": 25.0}, {"kg_per_unit": 30.0}, LABELS),
|
||||
)
|
||||
# A different entity must not leak into entity 7's history.
|
||||
record_change(db, session=session, entity_type=ENTITY_MIX, entity_id=7, action="created", summary="Created mix")
|
||||
db.commit()
|
||||
|
||||
events = list_changes(db, tenant_id=TENANT, entity_type=ENTITY_INGREDIENT, entity_id=7)
|
||||
assert [event.action for event in events] == ["updated", "created"]
|
||||
assert events[0].actor_name == "Lara"
|
||||
assert events[0].actor_role == "admin"
|
||||
assert events[0].changes[0]["label"] == "Kg per unit"
|
||||
|
||||
|
||||
def test_changes_are_tenant_scoped():
|
||||
db = _session()
|
||||
record_change(
|
||||
db,
|
||||
session=AuthSession(role="internal", email="x@y.test", name="X", tenant_id="other-tenant"),
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=1,
|
||||
action="created",
|
||||
summary="Created mix",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert list_changes(db, tenant_id=TENANT, entity_type=ENTITY_MIX, entity_id=1) == []
|
||||
@@ -482,7 +482,13 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
||||
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
|
||||
assert options_response.status_code == 200
|
||||
options_payload = options_response.json()
|
||||
assert len(options_payload["products"]) == 84
|
||||
# 83 product-backed mixes + 1 formula-only mix ("Hi Carb Popcorn", which
|
||||
# has a mix-master formula but no product yet, surfaced via a negative
|
||||
# product_id sentinel so a new mix is usable before a product is linked).
|
||||
assert len(options_payload["products"]) == 84 + 1
|
||||
formula_only = [product for product in options_payload["products"] if product["product_id"] < 0]
|
||||
assert len(formula_only) == 1
|
||||
assert formula_only[0]["unit_size_kg"] == 0
|
||||
seeded_product = next(
|
||||
product
|
||||
for product in options_payload["products"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.26",
|
||||
"version": "0.1.36",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 mix’s resolved formula directly, with % and kg entry on each row.',
|
||||
'Editor: New mix and new ingredient buttons.',
|
||||
'Throughput: Power BI / external API now live.',
|
||||
'App: Security hardening on API responses.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.23',
|
||||
date: '2026-06-15',
|
||||
highlights: [
|
||||
'App: Improvements & bug fixes.'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.22',
|
||||
date: '2026-06-15',
|
||||
@@ -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',
|
||||
@@ -62,9 +165,16 @@ export const changelog: ChangelogEntry[] = [
|
||||
highlights: [
|
||||
'Mix Calculator: Changed from selecting Product to Mix.',
|
||||
'Web app design improved',
|
||||
'Throughput tab ready for testing',
|
||||
'Throughput tab ready for testing',
|
||||
'Costing Editor tab ready for testing'
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '0.1.11',
|
||||
date: '2026-06-03',
|
||||
highlights: [
|
||||
'Costing Editor: First release.'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
</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')) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 0–100 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user