Dockerfile updates
This commit is contained in:
@@ -9,6 +9,7 @@ RUN addgroup --system app && adduser --system --ingroup app app
|
|||||||
|
|
||||||
COPY backend /app
|
COPY backend /app
|
||||||
COPY ["input_data/1.xlsx", "/app/input_data/1.xlsx"]
|
COPY ["input_data/1.xlsx", "/app/input_data/1.xlsx"]
|
||||||
|
COPY ["input_data/Operations Throughput.xlsx", "/app/input_data/Operations Throughput.xlsx"]
|
||||||
COPY ["Input Cost Spreadsheet(1).xlsx", "/app/Input Cost Spreadsheet(1).xlsx"]
|
COPY ["Input Cost Spreadsheet(1).xlsx", "/app/Input Cost Spreadsheet(1).xlsx"]
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ def create_entry(
|
|||||||
label_correct=payload.label_correct,
|
label_correct=payload.label_correct,
|
||||||
bag_sealed=payload.bag_sealed,
|
bag_sealed=payload.bag_sealed,
|
||||||
pallet_good_condition=payload.pallet_good_condition,
|
pallet_good_condition=payload.pallet_good_condition,
|
||||||
|
for_order=payload.for_order,
|
||||||
|
for_stock=payload.for_stock,
|
||||||
|
job_number=payload.job_number,
|
||||||
|
stock_quantity=payload.stock_quantity if payload.for_stock else None,
|
||||||
sample_box_no=payload.sample_box_no,
|
sample_box_no=payload.sample_box_no,
|
||||||
test_weight_1=payload.test_weight_1,
|
test_weight_1=payload.test_weight_1,
|
||||||
test_weight_2=payload.test_weight_2,
|
test_weight_2=payload.test_weight_2,
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
|
|||||||
("users", "password_hash", "VARCHAR(255)"),
|
("users", "password_hash", "VARCHAR(255)"),
|
||||||
("products", "visible", "BOOLEAN NOT NULL DEFAULT TRUE"),
|
("products", "visible", "BOOLEAN NOT NULL DEFAULT TRUE"),
|
||||||
("throughput_products", "is_stock_item", "BOOLEAN NOT NULL DEFAULT TRUE"),
|
("throughput_products", "is_stock_item", "BOOLEAN NOT NULL DEFAULT TRUE"),
|
||||||
|
("throughput_products", "client_name", "VARCHAR(255)"),
|
||||||
|
("production_throughput_entries", "for_order", "BOOLEAN NOT NULL DEFAULT FALSE"),
|
||||||
|
("production_throughput_entries", "for_stock", "BOOLEAN NOT NULL DEFAULT FALSE"),
|
||||||
|
("production_throughput_entries", "job_number", "VARCHAR(64)"),
|
||||||
|
("production_throughput_entries", "stock_quantity", "FLOAT"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class ThroughputProduct(Base):
|
|||||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||||
item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
default_bag_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
default_bag_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
is_bulka_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_bulka_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
@@ -48,6 +49,15 @@ class ProductionThroughput(Base):
|
|||||||
bag_sealed: Mapped[bool] = mapped_column(Boolean, default=True)
|
bag_sealed: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
pallet_good_condition: Mapped[bool] = mapped_column(Boolean, default=True)
|
pallet_good_condition: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Where the run is destined. A run can be for a client order, for stock, or
|
||||||
|
# split across both. job_number records the Order Circle job for the order
|
||||||
|
# portion; stock_quantity records how much of a split goes into stock (in the
|
||||||
|
# same unit as `quantity`).
|
||||||
|
for_order: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
for_stock: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
job_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
stock_quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
sample_box_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
sample_box_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
test_weight_1: Mapped[float | None] = mapped_column(Float, nullable=True)
|
test_weight_1: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ThroughputProductBase(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
item_id: str | None = Field(default=None, max_length=64)
|
item_id: str | None = Field(default=None, max_length=64)
|
||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
client_name: str | None = Field(default=None, max_length=255)
|
||||||
default_bag_size: float | None = Field(default=None, ge=0)
|
default_bag_size: float | None = Field(default=None, ge=0)
|
||||||
is_bulka_default: bool = False
|
is_bulka_default: bool = False
|
||||||
active: bool = True
|
active: bool = True
|
||||||
@@ -28,6 +29,7 @@ class ThroughputProductUpdate(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
item_id: str | None = Field(default=None, max_length=64)
|
item_id: str | None = Field(default=None, max_length=64)
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
client_name: str | None = Field(default=None, max_length=255)
|
||||||
default_bag_size: float | None = Field(default=None, ge=0)
|
default_bag_size: float | None = Field(default=None, ge=0)
|
||||||
is_bulka_default: bool | None = None
|
is_bulka_default: bool | None = None
|
||||||
active: bool | None = None
|
active: bool | None = None
|
||||||
@@ -53,6 +55,10 @@ class ThroughputEntryBase(BaseModel):
|
|||||||
label_correct: bool = True
|
label_correct: bool = True
|
||||||
bag_sealed: bool = True
|
bag_sealed: bool = True
|
||||||
pallet_good_condition: bool = True
|
pallet_good_condition: bool = True
|
||||||
|
for_order: bool = False
|
||||||
|
for_stock: bool = False
|
||||||
|
job_number: str | None = Field(default=None, max_length=64)
|
||||||
|
stock_quantity: float | None = Field(default=None, ge=0)
|
||||||
sample_box_no: str | None = Field(default=None, max_length=64)
|
sample_box_no: str | None = Field(default=None, max_length=64)
|
||||||
test_weight_1: float | None = Field(default=None, ge=0)
|
test_weight_1: float | None = Field(default=None, ge=0)
|
||||||
test_weight_2: float | None = Field(default=None, ge=0)
|
test_weight_2: float | None = Field(default=None, ge=0)
|
||||||
@@ -64,6 +70,14 @@ class ThroughputEntryBase(BaseModel):
|
|||||||
staff_name: str | None = Field(default=None, max_length=255)
|
staff_name: str | None = Field(default=None, max_length=255)
|
||||||
notes: str | None = Field(default=None, max_length=2000)
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
@field_validator("job_number")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_job_number(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
return stripped or None
|
||||||
|
|
||||||
@field_validator("staff_name")
|
@field_validator("staff_name")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_staff(cls, value: str | None) -> str | None:
|
def _normalize_staff(cls, value: str | None) -> str | None:
|
||||||
@@ -87,6 +101,10 @@ class ThroughputEntryUpdate(BaseModel):
|
|||||||
label_correct: bool | None = None
|
label_correct: bool | None = None
|
||||||
bag_sealed: bool | None = None
|
bag_sealed: bool | None = None
|
||||||
pallet_good_condition: bool | None = None
|
pallet_good_condition: bool | None = None
|
||||||
|
for_order: bool | None = None
|
||||||
|
for_stock: bool | None = None
|
||||||
|
job_number: str | None = Field(default=None, max_length=64)
|
||||||
|
stock_quantity: float | None = Field(default=None, ge=0)
|
||||||
sample_box_no: str | None = Field(default=None, max_length=64)
|
sample_box_no: str | None = Field(default=None, max_length=64)
|
||||||
test_weight_1: float | None = Field(default=None, ge=0)
|
test_weight_1: float | None = Field(default=None, ge=0)
|
||||||
test_weight_2: float | None = Field(default=None, ge=0)
|
test_weight_2: float | None = Field(default=None, ge=0)
|
||||||
@@ -110,6 +128,10 @@ class ThroughputEntryRead(BaseModel):
|
|||||||
label_correct: bool
|
label_correct: bool
|
||||||
bag_sealed: bool
|
bag_sealed: bool
|
||||||
pallet_good_condition: bool
|
pallet_good_condition: bool
|
||||||
|
for_order: bool
|
||||||
|
for_stock: bool
|
||||||
|
job_number: str | None
|
||||||
|
stock_quantity: float | None
|
||||||
sample_box_no: str | None
|
sample_box_no: str | None
|
||||||
test_weight_1: float | None
|
test_weight_1: float | None
|
||||||
test_weight_2: float | None
|
test_weight_2: float | None
|
||||||
|
|||||||
+10
-3
@@ -794,15 +794,19 @@ def seed_throughput_products_from_costing(db) -> dict[str, int]:
|
|||||||
|
|
||||||
default_bag_size = _infer_throughput_bag_size(costing_product)
|
default_bag_size = _infer_throughput_bag_size(costing_product)
|
||||||
is_bulka_default = _infer_throughput_bulka_default(costing_product)
|
is_bulka_default = _infer_throughput_bulka_default(costing_product)
|
||||||
|
client_name = (costing_product.client_name or "").strip() or None
|
||||||
product = (by_item.get(item_id) if item_id else None) or by_name.get(name_key)
|
product = (by_item.get(item_id) if item_id else None) or by_name.get(name_key)
|
||||||
if product is None:
|
if product is None:
|
||||||
product = ThroughputProduct(
|
product = ThroughputProduct(
|
||||||
tenant_id=TENANT_ID,
|
tenant_id=TENANT_ID,
|
||||||
item_id=item_id,
|
item_id=item_id,
|
||||||
name=name,
|
name=name,
|
||||||
|
client_name=client_name,
|
||||||
default_bag_size=default_bag_size,
|
default_bag_size=default_bag_size,
|
||||||
is_bulka_default=is_bulka_default,
|
is_bulka_default=is_bulka_default,
|
||||||
active=costing_product.visible,
|
# Every costing SKU should be selectable in the throughput picker
|
||||||
|
# (the Client filter + search keep the long list manageable).
|
||||||
|
active=True,
|
||||||
is_stock_item=True,
|
is_stock_item=True,
|
||||||
notes="Seeded from costing products",
|
notes="Seeded from costing products",
|
||||||
)
|
)
|
||||||
@@ -830,8 +834,11 @@ def seed_throughput_products_from_costing(db) -> dict[str, int]:
|
|||||||
if product.is_bulka_default != is_bulka_default:
|
if product.is_bulka_default != is_bulka_default:
|
||||||
product.is_bulka_default = is_bulka_default
|
product.is_bulka_default = is_bulka_default
|
||||||
changed = True
|
changed = True
|
||||||
if product.active != costing_product.visible:
|
if product.client_name != client_name:
|
||||||
product.active = costing_product.visible
|
product.client_name = client_name
|
||||||
|
changed = True
|
||||||
|
if product.active is not True:
|
||||||
|
product.active = True
|
||||||
changed = True
|
changed = True
|
||||||
if product.is_stock_item is not True:
|
if product.is_stock_item is not True:
|
||||||
product.is_stock_item = True
|
product.is_stock_item = True
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ ROLE_DEFINITIONS: dict[str, dict] = {
|
|||||||
"edit_raw_materials",
|
"edit_raw_materials",
|
||||||
"view_products",
|
"view_products",
|
||||||
"view_mixes",
|
"view_mixes",
|
||||||
|
"edit_mixes",
|
||||||
"view_throughput",
|
"view_throughput",
|
||||||
"edit_throughput",
|
"edit_throughput",
|
||||||
"view_users",
|
"view_users",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
@@ -16,6 +17,10 @@ logger = logging.getLogger("data_entry_app.throughput")
|
|||||||
PRODUCTION_SHEET = "Production"
|
PRODUCTION_SHEET = "Production"
|
||||||
NAMES_SHEET = "Names"
|
NAMES_SHEET = "Names"
|
||||||
|
|
||||||
|
# The historical throughput export. Bundled into the image under input_data/ so
|
||||||
|
# the seed can import it on a fresh deployment (e.g. a new Postgres volume).
|
||||||
|
WORKBOOK_FILENAME = "Operations Throughput.xlsx"
|
||||||
|
|
||||||
# Anything at or above this kg/bag is treated as a bulka batch, not a per-bag count.
|
# Anything at or above this kg/bag is treated as a bulka batch, not a per-bag count.
|
||||||
_BULKA_BAG_SIZE_THRESHOLD = 100.0
|
_BULKA_BAG_SIZE_THRESHOLD = 100.0
|
||||||
|
|
||||||
@@ -57,6 +62,10 @@ def serialize_entry(entry: ProductionThroughput) -> dict:
|
|||||||
"label_correct": entry.label_correct,
|
"label_correct": entry.label_correct,
|
||||||
"bag_sealed": entry.bag_sealed,
|
"bag_sealed": entry.bag_sealed,
|
||||||
"pallet_good_condition": entry.pallet_good_condition,
|
"pallet_good_condition": entry.pallet_good_condition,
|
||||||
|
"for_order": entry.for_order,
|
||||||
|
"for_stock": entry.for_stock,
|
||||||
|
"job_number": entry.job_number,
|
||||||
|
"stock_quantity": entry.stock_quantity,
|
||||||
"sample_box_no": entry.sample_box_no,
|
"sample_box_no": entry.sample_box_no,
|
||||||
"test_weight_1": entry.test_weight_1,
|
"test_weight_1": entry.test_weight_1,
|
||||||
"test_weight_2": entry.test_weight_2,
|
"test_weight_2": entry.test_weight_2,
|
||||||
@@ -323,16 +332,30 @@ def import_workbook(db: Session, workbook_path: Path, tenant_id: str) -> dict:
|
|||||||
|
|
||||||
def workbook_candidates() -> Iterable[Path]:
|
def workbook_candidates() -> Iterable[Path]:
|
||||||
repo_root = Path(__file__).resolve().parents[3]
|
repo_root = Path(__file__).resolve().parents[3]
|
||||||
|
cwd = Path.cwd()
|
||||||
|
|
||||||
|
env_value = os.getenv("THROUGHPUT_WORKBOOK_PATH")
|
||||||
|
env_path = Path(env_value.strip()) if isinstance(env_value, str) and env_value.strip() else None
|
||||||
|
|
||||||
|
# input_data/ is where the workbook is bundled in the image; in the
|
||||||
|
# container the working directory is /app, so cwd/input_data resolves it.
|
||||||
candidates = [
|
candidates = [
|
||||||
repo_root / "Operations Throughput.xlsx",
|
env_path,
|
||||||
repo_root.parent / "Operations Throughput.xlsx",
|
repo_root / "input_data" / WORKBOOK_FILENAME,
|
||||||
Path.cwd() / "Operations Throughput.xlsx",
|
cwd / "input_data" / WORKBOOK_FILENAME,
|
||||||
Path("/srv/lean101-clients") / "Operations Throughput.xlsx",
|
Path("/app") / "input_data" / WORKBOOK_FILENAME,
|
||||||
Path("/app") / "Operations Throughput.xlsx",
|
Path("/srv/lean101-clients") / "input_data" / WORKBOOK_FILENAME,
|
||||||
|
repo_root / WORKBOOK_FILENAME,
|
||||||
|
repo_root.parent / WORKBOOK_FILENAME,
|
||||||
|
cwd / WORKBOOK_FILENAME,
|
||||||
|
Path("/srv/lean101-clients") / WORKBOOK_FILENAME,
|
||||||
|
Path("/app") / WORKBOOK_FILENAME,
|
||||||
]
|
]
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
ordered: list[Path] = []
|
ordered: list[Path] = []
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
key = str(candidate)
|
key = str(candidate)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "data-entry-app-backend"
|
name = "data-entry-app-backend"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
description = "Costing platform MVP backend"
|
description = "Costing platform MVP backend"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.db.session import Base, get_db
|
||||||
|
from app.core.access import INTERNAL_USER_SUBJECT, INTERNAL_USER_TENANT_ID
|
||||||
|
from app.core.security import issue_token
|
||||||
|
from app.models.access import User
|
||||||
|
from app.models.throughput import ThroughputProduct
|
||||||
|
from app.api.throughput import router as throughput_router
|
||||||
|
from app.seed import seed_access
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
db = sessionmaker(bind=engine, expire_on_commit=False)()
|
||||||
|
seed_access(db)
|
||||||
|
|
||||||
|
product = ThroughputProduct(
|
||||||
|
tenant_id=INTERNAL_USER_TENANT_ID,
|
||||||
|
name="Test Product 20kg",
|
||||||
|
default_bag_size=20,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.dependency_overrides[get_db] = lambda: iter([db])
|
||||||
|
app.include_router(throughput_router)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
|
||||||
|
token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": ops.id, "email": ops.email})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"production_date": "2026-06-01",
|
||||||
|
"product_id": product.id,
|
||||||
|
"product_name_snapshot": "Test Product 20kg",
|
||||||
|
"bag_size": 20,
|
||||||
|
"quantity": 10,
|
||||||
|
"quantity_type": "bags",
|
||||||
|
"for_order": True,
|
||||||
|
"for_stock": False,
|
||||||
|
"job_number": "JOB123",
|
||||||
|
"stock_quantity": None,
|
||||||
|
"staff_name": "Jake",
|
||||||
|
"notes": None,
|
||||||
|
}
|
||||||
|
resp = client.post(
|
||||||
|
"/api/throughput/entries",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
print("STATUS:", resp.status_code)
|
||||||
|
print("BODY:", resp.text[:1500])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -197,9 +197,12 @@ def test_seed_throughput_products_from_costing_products():
|
|||||||
assert products[0].default_bag_size == 20
|
assert products[0].default_bag_size == 20
|
||||||
assert products[0].is_bulka_default is False
|
assert products[0].is_bulka_default is False
|
||||||
assert products[0].active is True
|
assert products[0].active is True
|
||||||
|
assert products[0].client_name == "Hunter"
|
||||||
assert products[1].default_bag_size is None
|
assert products[1].default_bag_size is None
|
||||||
assert products[1].is_bulka_default is True
|
assert products[1].is_bulka_default is True
|
||||||
assert products[1].active is False
|
# Every costing SKU is selectable in the throughput picker, even hidden ones.
|
||||||
|
assert products[1].active is True
|
||||||
|
assert products[1].client_name == "Hunter"
|
||||||
|
|
||||||
|
|
||||||
def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
|
def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
|
||||||
|
|||||||
@@ -405,6 +405,8 @@ TABLE_ORDER = [
|
|||||||
"costing_results",
|
"costing_results",
|
||||||
"mix_calculator_sessions",
|
"mix_calculator_sessions",
|
||||||
"mix_calculator_session_lines",
|
"mix_calculator_session_lines",
|
||||||
|
"throughput_products",
|
||||||
|
"production_throughput_entries",
|
||||||
]
|
]
|
||||||
|
|
||||||
def migrate():
|
def migrate():
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ThroughputProduct } from '$lib/types';
|
||||||
|
import { Search, X, Check } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
products = [],
|
||||||
|
productId = $bindable(''),
|
||||||
|
disabled = false,
|
||||||
|
inputId = 'throughput-product'
|
||||||
|
}: {
|
||||||
|
products?: ThroughputProduct[];
|
||||||
|
productId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
inputId?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let clientName = $state('');
|
||||||
|
let query = $state('');
|
||||||
|
let open = $state(false);
|
||||||
|
let highlighted = $state(-1);
|
||||||
|
let focused = $state(false);
|
||||||
|
let root = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
function label(product: ThroughputProduct): string {
|
||||||
|
return product.item_id ? `${product.name} · ${product.item_id}` : product.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = $derived(
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
products
|
||||||
|
.map((p) => (p.client_name ?? '').trim())
|
||||||
|
.filter((name) => name.length > 0)
|
||||||
|
)
|
||||||
|
).sort((a, b) => a.localeCompare(b))
|
||||||
|
);
|
||||||
|
|
||||||
|
const selected = $derived(
|
||||||
|
productId ? products.find((p) => String(p.id) === productId) ?? null : null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Products narrowed by the chosen client, then by the search text. Item id and
|
||||||
|
// name are both searchable so operators can type either.
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return products.filter((product) => {
|
||||||
|
if (clientName && (product.client_name ?? '') !== clientName) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
product.name.toLowerCase().includes(q) ||
|
||||||
|
(product.item_id ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the text box in sync when the selection is cleared from outside (for
|
||||||
|
// example after "Save and add another" resets the form).
|
||||||
|
$effect(() => {
|
||||||
|
if (!productId && !focused) {
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the active client no longer contains the selected product, drop it.
|
||||||
|
$effect(() => {
|
||||||
|
if (selected && clientName && (selected.client_name ?? '') !== clientName) {
|
||||||
|
productId = '';
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function choose(product: ThroughputProduct) {
|
||||||
|
productId = String(product.id);
|
||||||
|
query = label(product);
|
||||||
|
open = false;
|
||||||
|
highlighted = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
productId = '';
|
||||||
|
query = '';
|
||||||
|
open = false;
|
||||||
|
highlighted = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(event: Event) {
|
||||||
|
query = (event.target as HTMLInputElement).value;
|
||||||
|
productId = '';
|
||||||
|
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="client-row">
|
||||||
|
<label class="client-label" for={`${inputId}-client`}>Client</label>
|
||||||
|
<select
|
||||||
|
id={`${inputId}-client`}
|
||||||
|
class="client-select"
|
||||||
|
bind:value={clientName}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<option value="">All clients</option>
|
||||||
|
{#each clients as client (client)}
|
||||||
|
<option value={client}>{client}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 product or item id…"
|
||||||
|
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 product">
|
||||||
|
<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 products match.</li>
|
||||||
|
{:else}
|
||||||
|
{#each filtered.slice(0, 50) as product, i (product.id)}
|
||||||
|
<li
|
||||||
|
class="option"
|
||||||
|
class:highlighted={i === highlighted}
|
||||||
|
class:selected={String(product.id) === productId}
|
||||||
|
role="option"
|
||||||
|
aria-selected={String(product.id) === productId}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
choose(product);
|
||||||
|
}}
|
||||||
|
onmouseenter={() => (highlighted = i)}
|
||||||
|
>
|
||||||
|
<span class="option-name">{product.name}</span>
|
||||||
|
<span class="option-meta">
|
||||||
|
{#if product.client_name}<span class="option-client">{product.client_name}</span>{/if}
|
||||||
|
{#if product.item_id}<span class="option-item">#{product.item_id}</span>{/if}
|
||||||
|
</span>
|
||||||
|
{#if String(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;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.client-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.client-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted, #6b7280);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.client-select {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--color-bg-surface, #fff);
|
||||||
|
color: var(--color-text-primary, #111827);
|
||||||
|
}
|
||||||
|
.combo {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.combo-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.6rem;
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--color-text-muted, #6b7280);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.combo-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 0.55rem 2rem 0.55rem 2rem;
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--color-bg-surface, #fff);
|
||||||
|
color: var(--color-text-primary, #111827);
|
||||||
|
}
|
||||||
|
.combo-input:focus-visible {
|
||||||
|
outline: 3px solid var(--color-brand, #157f3a);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--color-brand, #157f3a);
|
||||||
|
}
|
||||||
|
.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, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.combo-clear:hover {
|
||||||
|
background: var(--color-bg-app, #f3f4f6);
|
||||||
|
color: var(--color-text-primary, #111827);
|
||||||
|
}
|
||||||
|
.options {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg-surface, #fff);
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
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, #111827);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.option.highlighted {
|
||||||
|
background: var(--color-brand-tint, #e8f5ec);
|
||||||
|
}
|
||||||
|
.option.selected {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.option.empty,
|
||||||
|
.option.more {
|
||||||
|
color: var(--color-text-muted, #6b7280);
|
||||||
|
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-client {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-secondary, #4b5563);
|
||||||
|
background: var(--color-bg-app, #f3f4f6);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.option-item {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #6b7280);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.option-check {
|
||||||
|
color: var(--color-brand, #157f3a);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -394,6 +394,7 @@ export type ThroughputProduct = {
|
|||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
item_id: string | null;
|
item_id: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
client_name: string | null;
|
||||||
default_bag_size: number | null;
|
default_bag_size: number | null;
|
||||||
is_bulka_default: boolean;
|
is_bulka_default: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -416,6 +417,10 @@ export type ThroughputEntry = {
|
|||||||
label_correct: boolean;
|
label_correct: boolean;
|
||||||
bag_sealed: boolean;
|
bag_sealed: boolean;
|
||||||
pallet_good_condition: boolean;
|
pallet_good_condition: boolean;
|
||||||
|
for_order: boolean;
|
||||||
|
for_stock: boolean;
|
||||||
|
job_number: string | null;
|
||||||
|
stock_quantity: number | null;
|
||||||
sample_box_no: string | null;
|
sample_box_no: string | null;
|
||||||
test_weight_1: number | null;
|
test_weight_1: number | null;
|
||||||
test_weight_2: number | null;
|
test_weight_2: number | null;
|
||||||
@@ -442,6 +447,10 @@ export type ThroughputEntryCreateInput = {
|
|||||||
label_correct?: boolean;
|
label_correct?: boolean;
|
||||||
bag_sealed?: boolean;
|
bag_sealed?: boolean;
|
||||||
pallet_good_condition?: boolean;
|
pallet_good_condition?: boolean;
|
||||||
|
for_order?: boolean;
|
||||||
|
for_stock?: boolean;
|
||||||
|
job_number?: string | null;
|
||||||
|
stock_quantity?: number | null;
|
||||||
sample_box_no?: string | null;
|
sample_box_no?: string | null;
|
||||||
test_weight_1?: number | null;
|
test_weight_1?: number | null;
|
||||||
test_weight_2?: number | null;
|
test_weight_2?: number | null;
|
||||||
@@ -466,6 +475,7 @@ export type ThroughputEntryListParams = {
|
|||||||
export type ThroughputProductCreateInput = {
|
export type ThroughputProductCreateInput = {
|
||||||
item_id?: string | null;
|
item_id?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
client_name?: string | null;
|
||||||
default_bag_size?: number | null;
|
default_bag_size?: number | null;
|
||||||
is_bulka_default?: boolean;
|
is_bulka_default?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
|||||||
@@ -6,18 +6,37 @@
|
|||||||
ThroughputProduct,
|
ThroughputProduct,
|
||||||
ThroughputQuantityType
|
ThroughputQuantityType
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import { Plus, ShieldCheck, TriangleAlert, Search, X } from 'lucide-svelte';
|
import { Plus, TriangleAlert, Search, X } from 'lucide-svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
|
||||||
|
|
||||||
let { data } = $props<{ data: { entries: ThroughputEntry[]; products: ThroughputProduct[] } }>();
|
let { data } = $props<{ data: { entries: ThroughputEntry[]; products: ThroughputProduct[] } }>();
|
||||||
|
|
||||||
let entries = $state<ThroughputEntry[]>(data.entries ?? []);
|
let entries = $state<ThroughputEntry[]>(data.entries ?? []);
|
||||||
const products = $derived(data.products ?? []);
|
const products = $derived(data.products ?? []);
|
||||||
|
|
||||||
// A run is "going into stock" when the operator marks it so; everything else
|
// A run is "going into stock" when the operator ticks the stock checkpoint.
|
||||||
// is a client order. The marker is the word "Stock" in the notes field.
|
// Older imported rows pre-date that flag, so fall back to the notes marker.
|
||||||
function isStockEntry(entry: ThroughputEntry): boolean {
|
function isStockEntry(entry: ThroughputEntry): boolean {
|
||||||
return /stock/i.test(entry.notes ?? '');
|
return entry.for_stock || (!entry.for_order && /stock/i.test(entry.notes ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ──────────────────────────────
|
// ── Inline "spreadsheet" add row ──────────────────────────────
|
||||||
@@ -29,17 +48,20 @@
|
|||||||
let nBagSize = $state('');
|
let nBagSize = $state('');
|
||||||
let nStaff = $state('');
|
let nStaff = $state('');
|
||||||
let nNotes = $state('');
|
let nNotes = $state('');
|
||||||
// QA checks start unchecked on purpose: the operator must confirm each one.
|
// Destination checkpoints: a run can go to a client order, to stock, or both.
|
||||||
let nScalesChecked = $state(false);
|
let nForOrder = $state(false);
|
||||||
let nLabelCorrect = $state(false);
|
|
||||||
let nBagSealed = $state(false);
|
|
||||||
let nPalletGood = $state(false);
|
|
||||||
let nForStock = $state(false);
|
let nForStock = $state(false);
|
||||||
|
let nJobNumber = $state('');
|
||||||
|
let nStockQty = $state('');
|
||||||
let showNote = $state(false);
|
let showNote = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let addError = $state('');
|
let addError = $state('');
|
||||||
let highlightId = $state<number | null>(null);
|
let highlightId = $state<number | null>(null);
|
||||||
|
|
||||||
|
// When both checkpoints are ticked the run is split, so we need to know how
|
||||||
|
// much goes to stock (the rest belongs to the order).
|
||||||
|
const isSplit = $derived(nForOrder && nForStock);
|
||||||
|
|
||||||
const selectedNewProduct = $derived(
|
const selectedNewProduct = $derived(
|
||||||
nProductId ? products.find((p: ThroughputProduct) => String(p.id) === nProductId) ?? null : null
|
nProductId ? products.find((p: ThroughputProduct) => String(p.id) === nProductId) ?? null : null
|
||||||
);
|
);
|
||||||
@@ -88,11 +110,30 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fold the "going into stock" marker into the notes, which is where stock
|
if (!nForOrder && !nForStock) {
|
||||||
// runs are identified.
|
addError = 'Mark where this run goes: for an order, for stock, or both.';
|
||||||
let finalNotes = nNotes.trim();
|
return;
|
||||||
if (nForStock && !/stock/i.test(finalNotes)) {
|
}
|
||||||
finalNotes = finalNotes ? `Stock. ${finalNotes}` : 'Stock';
|
|
||||||
|
const job = nJobNumber.trim();
|
||||||
|
if (nForOrder && !job) {
|
||||||
|
addError = 'Enter the job number for the order.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a split, the operator records how much goes to stock; the remainder
|
||||||
|
// belongs to the order. Whole runs (stock-only or order-only) don't need it.
|
||||||
|
let stockQty: number | null = null;
|
||||||
|
if (isSplit) {
|
||||||
|
stockQty = toNum(nStockQty);
|
||||||
|
if (stockQty === null || stockQty <= 0) {
|
||||||
|
addError = `Enter how much goes to stock (in ${nType === 'bags' ? 'bags' : 'kg'}).`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stockQty >= qty) {
|
||||||
|
addError = 'Stock amount must be less than the total packed for a split.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saving = true;
|
saving = true;
|
||||||
@@ -104,12 +145,12 @@
|
|||||||
bag_size: nType === 'bags' ? bag : null,
|
bag_size: nType === 'bags' ? bag : null,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
quantity_type: nType,
|
quantity_type: nType,
|
||||||
scales_checked: nScalesChecked,
|
for_order: nForOrder,
|
||||||
label_correct: nLabelCorrect,
|
for_stock: nForStock,
|
||||||
bag_sealed: nBagSealed,
|
job_number: nForOrder ? job : null,
|
||||||
pallet_good_condition: nPalletGood,
|
stock_quantity: stockQty,
|
||||||
staff_name: nStaff.trim() || null,
|
staff_name: nStaff.trim() || null,
|
||||||
notes: finalNotes || null
|
notes: nNotes.trim() || null
|
||||||
});
|
});
|
||||||
entries = [created, ...entries];
|
entries = [created, ...entries];
|
||||||
highlightId = created.id;
|
highlightId = created.id;
|
||||||
@@ -122,11 +163,10 @@
|
|||||||
nType = 'bags';
|
nType = 'bags';
|
||||||
nBagSize = '';
|
nBagSize = '';
|
||||||
nNotes = '';
|
nNotes = '';
|
||||||
nScalesChecked = false;
|
nForOrder = false;
|
||||||
nLabelCorrect = false;
|
|
||||||
nBagSealed = false;
|
|
||||||
nPalletGood = false;
|
|
||||||
nForStock = false;
|
nForStock = false;
|
||||||
|
nJobNumber = '';
|
||||||
|
nStockQty = '';
|
||||||
showNote = false;
|
showNote = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addError = err instanceof Error ? err.message : 'Could not save. Please try again.';
|
addError = err instanceof Error ? err.message : 'Could not save. Please try again.';
|
||||||
@@ -185,15 +225,15 @@
|
|||||||
const totals = $derived.by(() => {
|
const totals = $derived.by(() => {
|
||||||
let totalKg = 0;
|
let totalKg = 0;
|
||||||
let totalBags = 0;
|
let totalBags = 0;
|
||||||
let qaFailures = 0;
|
let stockCount = 0;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
totalKg += entry.calculated_kg || 0;
|
totalKg += entry.calculated_kg || 0;
|
||||||
if (entry.quantity_type === 'bags') {
|
if (entry.quantity_type === 'bags') {
|
||||||
totalBags += entry.quantity || 0;
|
totalBags += entry.quantity || 0;
|
||||||
}
|
}
|
||||||
if (!entry.qa_passed) qaFailures += 1;
|
if (isStockEntry(entry)) stockCount += 1;
|
||||||
}
|
}
|
||||||
return { totalKg, totalBags, qaFailures, count: entries.length };
|
return { totalKg, totalBags, stockCount, count: entries.length };
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(value: string) {
|
function formatDate(value: string) {
|
||||||
@@ -226,27 +266,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="throughput">
|
<section class="throughput">
|
||||||
<div class="status-band" class:has-issues={totals.qaFailures > 0}>
|
<div class="status-band">
|
||||||
<div class="qa-status">
|
<div class="qa-status">
|
||||||
{#if totals.qaFailures > 0}
|
|
||||||
<span class="qa-icon"><TriangleAlert size={26} strokeWidth={2.2} /></span>
|
|
||||||
<span class="qa-words">
|
<span class="qa-words">
|
||||||
<strong>{formatNumber(totals.qaFailures)} {totals.qaFailures === 1 ? 'entry needs' : 'entries need'} attention</strong>
|
{#if totals.count > 0}
|
||||||
<small>A quality check did not pass. Look for the amber rows below.</small>
|
<strong>Production log</strong>
|
||||||
</span>
|
<small>{formatNumber(totals.stockCount)} of {formatNumber(totals.count)} {totals.count === 1 ? 'run' : 'runs'} going to stock.</small>
|
||||||
{:else if totals.count > 0}
|
|
||||||
<span class="qa-icon"><ShieldCheck size={26} strokeWidth={2.2} /></span>
|
|
||||||
<span class="qa-words">
|
|
||||||
<strong>All quality checks passed</strong>
|
|
||||||
<small>Every entry in this view is good to go.</small>
|
|
||||||
</span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="qa-icon qa-icon-quiet"><ShieldCheck size={26} strokeWidth={2.2} /></span>
|
|
||||||
<span class="qa-words">
|
|
||||||
<strong>Nothing logged yet</strong>
|
<strong>Nothing logged yet</strong>
|
||||||
<small>Fill the green row below to add your first entry.</small>
|
<small>Fill the green row below to add your first entry.</small>
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="facts">
|
<dl class="facts">
|
||||||
@@ -348,7 +378,7 @@
|
|||||||
<span class="col-product">Product</span>
|
<span class="col-product">Product</span>
|
||||||
<span class="col-packed">Packed</span>
|
<span class="col-packed">Packed</span>
|
||||||
<span class="col-staff">Packed by</span>
|
<span class="col-staff">Packed by</span>
|
||||||
<span class="col-qa">Quality</span>
|
<span class="col-dest">Destination</span>
|
||||||
<span class="col-notes-head">Notes</span>
|
<span class="col-notes-head">Notes</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -357,14 +387,9 @@
|
|||||||
<span class="cell-label">Date</span>
|
<span class="cell-label">Date</span>
|
||||||
<input type="date" bind:value={nDate} aria-label="Production date" />
|
<input type="date" bind:value={nDate} aria-label="Production date" />
|
||||||
</div>
|
</div>
|
||||||
<div class="add-cell">
|
<div class="add-cell add-product">
|
||||||
<span class="cell-label">Product</span>
|
<span class="cell-label">Product</span>
|
||||||
<select bind:value={nProductId} aria-label="Product">
|
<ThroughputProductPicker {products} bind:productId={nProductId} inputId="throughput-add-product" />
|
||||||
<option value="">Choose product…</option>
|
|
||||||
{#each products as p (p.id)}
|
|
||||||
<option value={String(p.id)}>{p.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="add-cell">
|
<div class="add-cell">
|
||||||
<span class="cell-label">Packed</span>
|
<span class="cell-label">Packed</span>
|
||||||
@@ -405,14 +430,37 @@
|
|||||||
<span class="cell-label">Packed by</span>
|
<span class="cell-label">Packed by</span>
|
||||||
<input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" />
|
<input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" />
|
||||||
</div>
|
</div>
|
||||||
<div class="add-cell add-qa">
|
<div class="add-cell add-dest">
|
||||||
<span class="cell-label">Quality checks</span>
|
<span class="cell-label">Destination</span>
|
||||||
<div class="qa-checks">
|
<div class="dest-options">
|
||||||
<label class="qa-check"><input type="checkbox" bind:checked={nScalesChecked} /> Scales checked</label>
|
<label class="dest-toggle" class:on={nForOrder}>
|
||||||
<label class="qa-check"><input type="checkbox" bind:checked={nLabelCorrect} /> Label correct</label>
|
<input type="checkbox" bind:checked={nForOrder} /> For an order
|
||||||
<label class="qa-check"><input type="checkbox" bind:checked={nBagSealed} /> Bag sealed</label>
|
</label>
|
||||||
<label class="qa-check"><input type="checkbox" bind:checked={nPalletGood} /> Pallet OK</label>
|
<label class="dest-toggle" class:on={nForStock}>
|
||||||
|
<input type="checkbox" bind:checked={nForStock} /> For stock
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{#if nForOrder}
|
||||||
|
<input
|
||||||
|
class="dest-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={nJobNumber}
|
||||||
|
placeholder="Job number (Order Circle)"
|
||||||
|
aria-label="Job number"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#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 class="add-cell add-action">
|
<div class="add-cell add-action">
|
||||||
<button type="submit" class="add-entry-button" disabled={saving}>
|
<button type="submit" class="add-entry-button" disabled={saving}>
|
||||||
@@ -422,9 +470,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="add-extra">
|
<div class="add-extra">
|
||||||
<label class="stock-toggle" class:on={nForStock}>
|
|
||||||
<input type="checkbox" bind:checked={nForStock} /> Going into stock
|
|
||||||
</label>
|
|
||||||
{#if showNote}
|
{#if showNote}
|
||||||
<input
|
<input
|
||||||
class="note-input"
|
class="note-input"
|
||||||
@@ -439,7 +484,7 @@
|
|||||||
{#if addError}
|
{#if addError}
|
||||||
<span class="add-error"><TriangleAlert size={15} strokeWidth={2.4} /> {addError}</span>
|
<span class="add-error"><TriangleAlert size={15} strokeWidth={2.4} /> {addError}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<a class="detail-link" href="/throughput/add">Full form for QA checks and test weights</a>
|
<a class="detail-link" href="/throughput/add">Open full form (sample box & test weights)</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -455,7 +500,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each entries as entry (entry.id)}
|
{#each entries as entry (entry.id)}
|
||||||
<div class="row" class:needs-attention={!entry.qa_passed} class:just-added={entry.id === highlightId}>
|
{@const dest = destinationOf(entry)}
|
||||||
|
<div class="row" class:just-added={entry.id === highlightId}>
|
||||||
<span class="col-date">
|
<span class="col-date">
|
||||||
<span class="cell-label">Date</span>
|
<span class="cell-label">Date</span>
|
||||||
{formatDate(entry.production_date)}
|
{formatDate(entry.production_date)}
|
||||||
@@ -463,9 +509,6 @@
|
|||||||
<span class="col-product">
|
<span class="col-product">
|
||||||
<span class="cell-label">Product</span>
|
<span class="cell-label">Product</span>
|
||||||
<span class="product-name">{entry.product_name_snapshot}</span>
|
<span class="product-name">{entry.product_name_snapshot}</span>
|
||||||
{#if isStockEntry(entry)}
|
|
||||||
<span class="stock-tag">Stock</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="col-packed">
|
<span class="col-packed">
|
||||||
<span class="cell-label">Packed</span>
|
<span class="cell-label">Packed</span>
|
||||||
@@ -476,13 +519,15 @@
|
|||||||
<span class="cell-label">Packed by</span>
|
<span class="cell-label">Packed by</span>
|
||||||
{entry.staff_name ?? '—'}
|
{entry.staff_name ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
<span class="col-qa">
|
<span class="col-dest">
|
||||||
<span class="cell-label">Quality</span>
|
<span class="cell-label">Destination</span>
|
||||||
{#if entry.qa_passed}
|
<span
|
||||||
<span class="pill pill-pass"><ShieldCheck size={16} strokeWidth={2.4} /> Passed</span>
|
class="pill"
|
||||||
{:else}
|
class:pill-stock={dest.label === 'Stock'}
|
||||||
<span class="pill pill-attention"><TriangleAlert size={16} strokeWidth={2.4} /> Needs a look</span>
|
class:pill-order={dest.label === 'Order'}
|
||||||
{/if}
|
class:pill-split={dest.label === 'Split'}
|
||||||
|
>{dest.label}</span>
|
||||||
|
{#if dest.detail}<span class="dest-detail">{dest.detail}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if entry.notes}
|
{#if entry.notes}
|
||||||
<p class="row-notes"><span class="cell-label">Note</span>{entry.notes}</p>
|
<p class="row-notes"><span class="cell-label">Note</span>{entry.notes}</p>
|
||||||
@@ -526,32 +571,11 @@
|
|||||||
border: 1px solid #bfe6c8;
|
border: 1px solid #bfe6c8;
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.9rem;
|
||||||
}
|
}
|
||||||
.status-band.has-issues {
|
|
||||||
background: #fdf6e9;
|
|
||||||
border-color: #ecd9a8;
|
|
||||||
}
|
|
||||||
.qa-status {
|
.qa-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
.qa-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
.status-band.has-issues .qa-icon {
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
.qa-icon-quiet {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.qa-words {
|
.qa-words {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -794,12 +818,6 @@
|
|||||||
.row:hover {
|
.row:hover {
|
||||||
background: #fafbfc;
|
background: #fafbfc;
|
||||||
}
|
}
|
||||||
.row.needs-attention {
|
|
||||||
background: #fdf6e9;
|
|
||||||
}
|
|
||||||
.row.needs-attention:hover {
|
|
||||||
background: #fbf0db;
|
|
||||||
}
|
|
||||||
.row.just-added {
|
.row.just-added {
|
||||||
animation: flash-in 1.8s ease-out;
|
animation: flash-in 1.8s ease-out;
|
||||||
}
|
}
|
||||||
@@ -816,24 +834,16 @@
|
|||||||
.product-name {
|
.product-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.stock-tag {
|
.col-dest {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
align-items: flex-start;
|
||||||
padding: 0.18rem 0.55rem;
|
gap: 0.25rem;
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 650;
|
|
||||||
line-height: 1.2;
|
|
||||||
background: #e8f1fc;
|
|
||||||
color: #0b5cad;
|
|
||||||
}
|
}
|
||||||
.stock-tag::before {
|
.dest-detail {
|
||||||
content: '';
|
font-size: 0.88rem;
|
||||||
width: 0.5rem;
|
color: var(--color-text-secondary);
|
||||||
height: 0.5rem;
|
font-variant-numeric: tabular-nums;
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
}
|
}
|
||||||
.col-packed {
|
.col-packed {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -868,13 +878,17 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.pill-pass {
|
.pill-stock {
|
||||||
|
background: #e8f1fc;
|
||||||
|
color: #0b5cad;
|
||||||
|
}
|
||||||
|
.pill-order {
|
||||||
background: var(--color-brand-tint);
|
background: var(--color-brand-tint);
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
.pill-attention {
|
.pill-split {
|
||||||
background: #fbedcf;
|
background: #f3e8fc;
|
||||||
color: #8a5a00;
|
color: #6b21a8;
|
||||||
}
|
}
|
||||||
.row-notes {
|
.row-notes {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
@@ -888,7 +902,7 @@
|
|||||||
/* ── Inline spreadsheet add row ───────────────────────────── */
|
/* ── Inline spreadsheet add row ───────────────────────────── */
|
||||||
.add-row {
|
.add-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 9rem minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, 1fr) 11rem auto;
|
grid-template-columns: 9rem minmax(0, 1.6fr) minmax(0, 1.3fr) minmax(0, 0.9fr) minmax(0, 1.6fr) auto;
|
||||||
gap: 0.6rem 1rem;
|
gap: 0.6rem 1rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 1rem 1.5rem 1.1rem;
|
padding: 1rem 1.5rem 1.1rem;
|
||||||
@@ -947,35 +961,49 @@
|
|||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.qa-checks {
|
.add-dest {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.dest-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.qa-check {
|
.dest-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.95rem;
|
padding: 0.3rem 0.6rem;
|
||||||
font-weight: 500;
|
border: 1px solid #aedcbb;
|
||||||
color: var(--color-text-primary);
|
border-radius: 0.5rem;
|
||||||
line-height: 1.15;
|
background: var(--color-bg-surface);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.qa-check input {
|
.dest-toggle input {
|
||||||
width: 1.3rem;
|
width: 1.2rem;
|
||||||
height: 1.3rem;
|
height: 1.2rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
accent-color: var(--color-brand);
|
accent-color: var(--color-brand);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.qa-check input:focus-visible {
|
.dest-toggle input:focus-visible {
|
||||||
outline: 2px solid var(--color-brand);
|
outline: 2px solid var(--color-brand);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
.dest-toggle.on {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background: var(--color-brand-tint);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.dest-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.add-action {
|
.add-action {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -1017,32 +1045,6 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.stock-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
padding: 0.35rem 0.7rem;
|
|
||||||
border: 1px solid #aedcbb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.stock-toggle input {
|
|
||||||
width: 1.2rem;
|
|
||||||
height: 1.2rem;
|
|
||||||
margin: 0;
|
|
||||||
accent-color: #0b5cad;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.stock-toggle.on {
|
|
||||||
border-color: #9ccaf3;
|
|
||||||
background: #e8f1fc;
|
|
||||||
color: #0b5cad;
|
|
||||||
}
|
|
||||||
.link-button {
|
.link-button {
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -1146,9 +1148,6 @@
|
|||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
}
|
}
|
||||||
.row.needs-attention {
|
|
||||||
border-color: #ecd9a8;
|
|
||||||
}
|
|
||||||
.col-product,
|
.col-product,
|
||||||
.row-notes {
|
.row-notes {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
@@ -1161,7 +1160,7 @@
|
|||||||
.col-product,
|
.col-product,
|
||||||
.col-packed,
|
.col-packed,
|
||||||
.col-staff,
|
.col-staff,
|
||||||
.col-qa {
|
.col-dest {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.05rem;
|
gap: 0.05rem;
|
||||||
@@ -1180,6 +1179,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.add-cell:nth-child(2),
|
.add-cell:nth-child(2),
|
||||||
|
.add-dest,
|
||||||
.add-action {
|
.add-action {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
ThroughputQuantityType
|
ThroughputQuantityType
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte';
|
import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte';
|
||||||
|
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
|
||||||
|
|
||||||
let { data } = $props<{ data: { products: ThroughputProduct[] } }>();
|
let { data } = $props<{ data: { products: ThroughputProduct[] } }>();
|
||||||
const products = $derived(data.products ?? []);
|
const products = $derived(data.products ?? []);
|
||||||
@@ -18,10 +19,10 @@
|
|||||||
let bagSize = $state<string>('');
|
let bagSize = $state<string>('');
|
||||||
let quantity = $state<string>('');
|
let quantity = $state<string>('');
|
||||||
let quantityType = $state<ThroughputQuantityType>('bags');
|
let quantityType = $state<ThroughputQuantityType>('bags');
|
||||||
let scalesChecked = $state(true);
|
let forOrder = $state(false);
|
||||||
let labelCorrect = $state(true);
|
let forStock = $state(false);
|
||||||
let bagSealed = $state(true);
|
let jobNumber = $state('');
|
||||||
let palletGood = $state(true);
|
let stockQty = $state('');
|
||||||
let sampleBoxNo = $state('');
|
let sampleBoxNo = $state('');
|
||||||
let tw1 = $state('');
|
let tw1 = $state('');
|
||||||
let tw2 = $state('');
|
let tw2 = $state('');
|
||||||
@@ -39,6 +40,8 @@
|
|||||||
productId ? products.find((p: ThroughputProduct) => String(p.id) === productId) ?? null : null
|
productId ? products.find((p: ThroughputProduct) => String(p.id) === productId) ?? null : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isSplit = $derived(forOrder && forStock);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedProduct) {
|
if (selectedProduct) {
|
||||||
if (!bagSize && selectedProduct.default_bag_size != null) {
|
if (!bagSize && selectedProduct.default_bag_size != null) {
|
||||||
@@ -50,8 +53,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const qaWarning = $derived(!(scalesChecked && labelCorrect && bagSealed && palletGood));
|
|
||||||
|
|
||||||
function toNum(value: string): number | null {
|
function toNum(value: string): number | null {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -64,10 +65,10 @@
|
|||||||
bagSize = '';
|
bagSize = '';
|
||||||
quantity = '';
|
quantity = '';
|
||||||
quantityType = 'bags';
|
quantityType = 'bags';
|
||||||
scalesChecked = true;
|
forOrder = false;
|
||||||
labelCorrect = true;
|
forStock = false;
|
||||||
bagSealed = true;
|
jobNumber = '';
|
||||||
palletGood = true;
|
stockQty = '';
|
||||||
sampleBoxNo = '';
|
sampleBoxNo = '';
|
||||||
tw1 = '';
|
tw1 = '';
|
||||||
tw2 = '';
|
tw2 = '';
|
||||||
@@ -97,15 +98,37 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!forOrder && !forStock) {
|
||||||
|
errorMessage = 'Mark where this run goes: for an order, for stock, or both.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const job = jobNumber.trim();
|
||||||
|
if (forOrder && !job) {
|
||||||
|
errorMessage = 'Enter the job number for the order.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let stock: number | null = null;
|
||||||
|
if (isSplit) {
|
||||||
|
stock = toNum(stockQty);
|
||||||
|
if (stock === null || stock <= 0) {
|
||||||
|
errorMessage = `Enter how much goes to stock (in ${quantityType === 'bags' ? 'bags' : 'kg'}).`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (stock >= qty) {
|
||||||
|
errorMessage = 'Stock amount must be less than the total packed for a split.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
production_date: productionDate,
|
production_date: productionDate,
|
||||||
product_id: Number(productId),
|
product_id: Number(productId),
|
||||||
product_name_snapshot: selectedProduct?.name ?? '',
|
product_name_snapshot: selectedProduct?.name ?? '',
|
||||||
bag_size: bag,
|
bag_size: bag,
|
||||||
scales_checked: scalesChecked,
|
for_order: forOrder,
|
||||||
label_correct: labelCorrect,
|
for_stock: forStock,
|
||||||
bag_sealed: bagSealed,
|
job_number: forOrder ? job : null,
|
||||||
pallet_good_condition: palletGood,
|
stock_quantity: stock,
|
||||||
sample_box_no: sampleBoxNo.trim() || null,
|
sample_box_no: sampleBoxNo.trim() || null,
|
||||||
test_weight_1: toNum(tw1),
|
test_weight_1: toNum(tw1),
|
||||||
test_weight_2: toNum(tw2),
|
test_weight_2: toNum(tw2),
|
||||||
@@ -162,15 +185,10 @@
|
|||||||
<input type="date" bind:value={productionDate} required />
|
<input type="date" bind:value={productionDate} required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<div class="picker-field">
|
||||||
<span>Product *</span>
|
<span class="picker-label">Product *</span>
|
||||||
<select bind:value={productId} required>
|
<ThroughputProductPicker {products} bind:productId inputId="throughput-full-product" />
|
||||||
<option value="">Select product…</option>
|
</div>
|
||||||
{#each products as p (p.id)}
|
|
||||||
<option value={String(p.id)}>{p.name}{p.item_id ? ` · ${p.item_id}` : ''}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Bag size (kg)</span>
|
<span>Bag size (kg)</span>
|
||||||
@@ -196,15 +214,26 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="qa">
|
<fieldset class="destination">
|
||||||
<legend>QA checklist</legend>
|
<legend>Where does this run go?</legend>
|
||||||
<label class="check"><input type="checkbox" bind:checked={scalesChecked} /> Scales checked</label>
|
<div class="dest-checks">
|
||||||
<label class="check"><input type="checkbox" bind:checked={labelCorrect} /> Label correct</label>
|
<label class="check"><input type="checkbox" bind:checked={forOrder} /> For an order</label>
|
||||||
<label class="check"><input type="checkbox" bind:checked={bagSealed} /> Bag sealed</label>
|
<label class="check"><input type="checkbox" bind:checked={forStock} /> For stock</label>
|
||||||
<label class="check"><input type="checkbox" bind:checked={palletGood} /> Pallet in good condition</label>
|
</div>
|
||||||
{#if qaWarning}
|
<div class="dest-fields">
|
||||||
<p class="qa-warning"><AlertTriangle size={14} /> One or more QA checks failed — this entry will be flagged.</p>
|
{#if forOrder}
|
||||||
|
<label>
|
||||||
|
<span>Job number (Order Circle)</span>
|
||||||
|
<input type="text" bind:value={jobNumber} placeholder="e.g. job number" />
|
||||||
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if isSplit}
|
||||||
|
<label>
|
||||||
|
<span>Amount going to stock ({quantityType === 'bags' ? 'bags' : 'kg'})</span>
|
||||||
|
<input type="number" min="0" step="0.01" bind:value={stockQty} placeholder="Remainder goes to the order" />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="weights">
|
<fieldset class="weights">
|
||||||
@@ -308,25 +337,30 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.qa {
|
.picker-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.picker-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.dest-checks {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1.25rem;
|
||||||
|
}
|
||||||
|
.dest-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 0.4rem 1rem;
|
gap: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
.check {
|
.check {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
.qa-warning {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
margin: 0;
|
|
||||||
color: #92400e;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.weight-grid {
|
.weight-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user