Dockerfile updates

This commit is contained in:
2026-06-02 15:41:53 +12:00
parent 84792c0947
commit f5a588d631
18 changed files with 742 additions and 220 deletions
+1
View File
@@ -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 && \
+4
View File
@@ -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,
+5
View File
@@ -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"),
) )
+10
View File
@@ -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)
+22
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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",
+28 -5
View File
@@ -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
+1 -1
View File
@@ -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 = [
+62
View File
@@ -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()
+4 -1
View File
@@ -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():
+2
View File
@@ -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 -1
View File
@@ -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>
+10
View File
@@ -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;
+162 -162
View File
@@ -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 &amp; 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;
} }
+77 -43
View File
@@ -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.