diff --git a/backend/Dockerfile b/backend/Dockerfile index f302af4..78cd4f3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,6 +9,7 @@ RUN addgroup --system app && adduser --system --ingroup app app COPY backend /app 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"] RUN pip install --no-cache-dir --upgrade pip && \ diff --git a/backend/app/api/throughput.py b/backend/app/api/throughput.py index 73c6c3e..38e5fd2 100644 --- a/backend/app/api/throughput.py +++ b/backend/app/api/throughput.py @@ -161,6 +161,10 @@ def create_entry( label_correct=payload.label_correct, bag_sealed=payload.bag_sealed, 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, test_weight_1=payload.test_weight_1, test_weight_2=payload.test_weight_2, diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index efbe34e..07dc0a2 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -103,6 +103,11 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = ( ("users", "password_hash", "VARCHAR(255)"), ("products", "visible", "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"), ) diff --git a/backend/app/models/throughput.py b/backend/app/models/throughput.py index 87bcd70..bd67497 100644 --- a/backend/app/models/throughput.py +++ b/backend/app/models/throughput.py @@ -19,6 +19,7 @@ class ThroughputProduct(Base): tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) item_id: Mapped[str | None] = mapped_column(String(64), nullable=True) 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) is_bulka_default: Mapped[bool] = mapped_column(Boolean, default=False) active: Mapped[bool] = mapped_column(Boolean, default=True) @@ -48,6 +49,15 @@ class ProductionThroughput(Base): bag_sealed: 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) test_weight_1: Mapped[float | None] = mapped_column(Float, nullable=True) diff --git a/backend/app/schemas/throughput.py b/backend/app/schemas/throughput.py index ac993f3..94ede52 100644 --- a/backend/app/schemas/throughput.py +++ b/backend/app/schemas/throughput.py @@ -13,6 +13,7 @@ class ThroughputProductBase(BaseModel): model_config = ConfigDict(extra="forbid") item_id: str | None = Field(default=None, max_length=64) 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) is_bulka_default: bool = False active: bool = True @@ -28,6 +29,7 @@ class ThroughputProductUpdate(BaseModel): model_config = ConfigDict(extra="forbid") item_id: str | None = Field(default=None, max_length=64) 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) is_bulka_default: bool | None = None active: bool | None = None @@ -53,6 +55,10 @@ class ThroughputEntryBase(BaseModel): label_correct: bool = True bag_sealed: 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) test_weight_1: 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) 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") @classmethod def _normalize_staff(cls, value: str | None) -> str | None: @@ -87,6 +101,10 @@ class ThroughputEntryUpdate(BaseModel): label_correct: bool | None = None bag_sealed: 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) test_weight_1: 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 bag_sealed: bool pallet_good_condition: bool + for_order: bool + for_stock: bool + job_number: str | None + stock_quantity: float | None sample_box_no: str | None test_weight_1: float | None test_weight_2: float | None diff --git a/backend/app/seed.py b/backend/app/seed.py index 3433b7d..bf190de 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -794,15 +794,19 @@ def seed_throughput_products_from_costing(db) -> dict[str, int]: default_bag_size = _infer_throughput_bag_size(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) if product is None: product = ThroughputProduct( tenant_id=TENANT_ID, item_id=item_id, name=name, + client_name=client_name, default_bag_size=default_bag_size, 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, 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: product.is_bulka_default = is_bulka_default changed = True - if product.active != costing_product.visible: - product.active = costing_product.visible + if product.client_name != client_name: + product.client_name = client_name + changed = True + if product.active is not True: + product.active = True changed = True if product.is_stock_item is not True: product.is_stock_item = True diff --git a/backend/app/seed_access.py b/backend/app/seed_access.py index 1966dda..99426cf 100644 --- a/backend/app/seed_access.py +++ b/backend/app/seed_access.py @@ -51,6 +51,7 @@ ROLE_DEFINITIONS: dict[str, dict] = { "edit_raw_materials", "view_products", "view_mixes", + "edit_mixes", "view_throughput", "edit_throughput", "view_users", diff --git a/backend/app/services/throughput_service.py b/backend/app/services/throughput_service.py index 6c70125..0b7f570 100644 --- a/backend/app/services/throughput_service.py +++ b/backend/app/services/throughput_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import os from datetime import date, datetime from pathlib import Path from typing import Iterable @@ -16,6 +17,10 @@ logger = logging.getLogger("data_entry_app.throughput") PRODUCTION_SHEET = "Production" 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. _BULKA_BAG_SIZE_THRESHOLD = 100.0 @@ -57,6 +62,10 @@ def serialize_entry(entry: ProductionThroughput) -> dict: "label_correct": entry.label_correct, "bag_sealed": entry.bag_sealed, "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, "test_weight_1": entry.test_weight_1, "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]: 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 = [ - repo_root / "Operations Throughput.xlsx", - repo_root.parent / "Operations Throughput.xlsx", - Path.cwd() / "Operations Throughput.xlsx", - Path("/srv/lean101-clients") / "Operations Throughput.xlsx", - Path("/app") / "Operations Throughput.xlsx", + env_path, + repo_root / "input_data" / WORKBOOK_FILENAME, + cwd / "input_data" / WORKBOOK_FILENAME, + Path("/app") / "input_data" / WORKBOOK_FILENAME, + 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() ordered: list[Path] = [] for candidate in candidates: + if candidate is None: + continue key = str(candidate) if key in seen: continue diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4af15b9..87eb17b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "data-entry-app-backend" -version = "0.1.8" +version = "0.1.9" description = "Costing platform MVP backend" requires-python = ">=3.11" dependencies = [ diff --git a/backend/tests/_repro_throughput_post.py b/backend/tests/_repro_throughput_post.py new file mode 100644 index 0000000..26f27c3 --- /dev/null +++ b/backend/tests/_repro_throughput_post.py @@ -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() diff --git a/backend/tests/test_throughput.py b/backend/tests/test_throughput.py index f9e4d60..5dcd095 100644 --- a/backend/tests/test_throughput.py +++ b/backend/tests/test_throughput.py @@ -197,9 +197,12 @@ def test_seed_throughput_products_from_costing_products(): assert products[0].default_bag_size == 20 assert products[0].is_bulka_default is False assert products[0].active is True + assert products[0].client_name == "Hunter" assert products[1].default_bag_size is None 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(): diff --git a/deploy/migrate-to-postgres.sh b/deploy/migrate-to-postgres.sh index b76fb88..aafd26e 100644 --- a/deploy/migrate-to-postgres.sh +++ b/deploy/migrate-to-postgres.sh @@ -405,6 +405,8 @@ TABLE_ORDER = [ "costing_results", "mix_calculator_sessions", "mix_calculator_session_lines", + "throughput_products", + "production_throughput_entries", ] def migrate(): diff --git a/frontend/package.json b/frontend/package.json index 4aa8a0a..4743465 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/throughput/ThroughputProductPicker.svelte b/frontend/src/lib/components/throughput/ThroughputProductPicker.svelte new file mode 100644 index 0000000..b26e091 --- /dev/null +++ b/frontend/src/lib/components/throughput/ThroughputProductPicker.svelte @@ -0,0 +1,338 @@ + + +
(focused = true)} onfocusout={onFocusOut}> +
+ + +
+ +
+ + (open = true)} + onkeydown={onKeydown} + /> + {#if productId} + + {/if} + + {#if open && !disabled} + + {/if} +
+
+ + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d18d64b..83f3c25 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -394,6 +394,7 @@ export type ThroughputProduct = { tenant_id: string; item_id: string | null; name: string; + client_name: string | null; default_bag_size: number | null; is_bulka_default: boolean; active: boolean; @@ -416,6 +417,10 @@ export type ThroughputEntry = { label_correct: boolean; bag_sealed: boolean; pallet_good_condition: boolean; + for_order: boolean; + for_stock: boolean; + job_number: string | null; + stock_quantity: number | null; sample_box_no: string | null; test_weight_1: number | null; test_weight_2: number | null; @@ -442,6 +447,10 @@ export type ThroughputEntryCreateInput = { label_correct?: boolean; bag_sealed?: boolean; pallet_good_condition?: boolean; + for_order?: boolean; + for_stock?: boolean; + job_number?: string | null; + stock_quantity?: number | null; sample_box_no?: string | null; test_weight_1?: number | null; test_weight_2?: number | null; @@ -466,6 +475,7 @@ export type ThroughputEntryListParams = { export type ThroughputProductCreateInput = { item_id?: string | null; name: string; + client_name?: string | null; default_bag_size?: number | null; is_bulka_default?: boolean; active?: boolean; diff --git a/frontend/src/routes/throughput/+page.svelte b/frontend/src/routes/throughput/+page.svelte index 6b67c7d..2d2ce44 100644 --- a/frontend/src/routes/throughput/+page.svelte +++ b/frontend/src/routes/throughput/+page.svelte @@ -6,18 +6,37 @@ ThroughputProduct, ThroughputQuantityType } 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 ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte'; let { data } = $props<{ data: { entries: ThroughputEntry[]; products: ThroughputProduct[] } }>(); let entries = $state(data.entries ?? []); const products = $derived(data.products ?? []); - // A run is "going into stock" when the operator marks it so; everything else - // is a client order. The marker is the word "Stock" in the notes field. + // A run is "going into stock" when the operator ticks the stock checkpoint. + // Older imported rows pre-date that flag, so fall back to the notes marker. 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 ────────────────────────────── @@ -29,17 +48,20 @@ let nBagSize = $state(''); let nStaff = $state(''); let nNotes = $state(''); - // QA checks start unchecked on purpose: the operator must confirm each one. - let nScalesChecked = $state(false); - let nLabelCorrect = $state(false); - let nBagSealed = $state(false); - let nPalletGood = $state(false); + // Destination checkpoints: a run can go to a client order, to stock, or both. + let nForOrder = $state(false); let nForStock = $state(false); + let nJobNumber = $state(''); + let nStockQty = $state(''); let showNote = $state(false); let saving = $state(false); let addError = $state(''); let highlightId = $state(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( nProductId ? products.find((p: ThroughputProduct) => String(p.id) === nProductId) ?? null : null ); @@ -88,11 +110,30 @@ return; } - // Fold the "going into stock" marker into the notes, which is where stock - // runs are identified. - let finalNotes = nNotes.trim(); - if (nForStock && !/stock/i.test(finalNotes)) { - finalNotes = finalNotes ? `Stock. ${finalNotes}` : 'Stock'; + if (!nForOrder && !nForStock) { + addError = 'Mark where this run goes: for an order, for stock, or both.'; + return; + } + + 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; @@ -104,12 +145,12 @@ bag_size: nType === 'bags' ? bag : null, quantity: qty, quantity_type: nType, - scales_checked: nScalesChecked, - label_correct: nLabelCorrect, - bag_sealed: nBagSealed, - pallet_good_condition: nPalletGood, + for_order: nForOrder, + for_stock: nForStock, + job_number: nForOrder ? job : null, + stock_quantity: stockQty, staff_name: nStaff.trim() || null, - notes: finalNotes || null + notes: nNotes.trim() || null }); entries = [created, ...entries]; highlightId = created.id; @@ -122,11 +163,10 @@ nType = 'bags'; nBagSize = ''; nNotes = ''; - nScalesChecked = false; - nLabelCorrect = false; - nBagSealed = false; - nPalletGood = false; + nForOrder = false; nForStock = false; + nJobNumber = ''; + nStockQty = ''; showNote = false; } catch (err) { addError = err instanceof Error ? err.message : 'Could not save. Please try again.'; @@ -185,15 +225,15 @@ const totals = $derived.by(() => { let totalKg = 0; let totalBags = 0; - let qaFailures = 0; + let stockCount = 0; for (const entry of entries) { totalKg += entry.calculated_kg || 0; if (entry.quantity_type === 'bags') { 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) { @@ -226,27 +266,17 @@
-
0}> +
- {#if totals.qaFailures > 0} - - - {formatNumber(totals.qaFailures)} {totals.qaFailures === 1 ? 'entry needs' : 'entries need'} attention - A quality check did not pass. Look for the amber rows below. - - {:else if totals.count > 0} - - - All quality checks passed - Every entry in this view is good to go. - - {:else} - - + + {#if totals.count > 0} + Production log + {formatNumber(totals.stockCount)} of {formatNumber(totals.count)} {totals.count === 1 ? 'run' : 'runs'} going to stock. + {:else} Nothing logged yet Fill the green row below to add your first entry. - - {/if} + {/if} +
@@ -348,7 +378,7 @@ Product Packed Packed by - Quality + Destination Notes
@@ -357,14 +387,9 @@ Date
-
+
Product - +
Packed @@ -405,14 +430,37 @@ Packed by
-
- Quality checks -
- - - - +
+ Destination +
+ +
+ {#if nForOrder} + + {/if} + {#if isSplit} + + {/if}
- {#if showNote} {addError} {/if} - Full form for QA checks and test weights + Open full form (sample box & test weights)
@@ -455,7 +500,8 @@ {/each} {:else} {#each entries as entry (entry.id)} -
+ {@const dest = destinationOf(entry)} +
Date {formatDate(entry.production_date)} @@ -463,9 +509,6 @@ Product {entry.product_name_snapshot} - {#if isStockEntry(entry)} - Stock - {/if} Packed @@ -476,13 +519,15 @@ Packed by {entry.staff_name ?? '—'} - - Quality - {#if entry.qa_passed} - Passed - {:else} - Needs a look - {/if} + + Destination + {dest.label} + {#if dest.detail}{dest.detail}{/if} {#if entry.notes}

Note{entry.notes}

@@ -526,32 +571,11 @@ border: 1px solid #bfe6c8; border-radius: 0.9rem; } - .status-band.has-issues { - background: #fdf6e9; - border-color: #ecd9a8; - } .qa-status { display: flex; align-items: center; 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 { display: flex; flex-direction: column; @@ -794,12 +818,6 @@ .row:hover { background: #fafbfc; } - .row.needs-attention { - background: #fdf6e9; - } - .row.needs-attention:hover { - background: #fbf0db; - } .row.just-added { animation: flash-in 1.8s ease-out; } @@ -816,24 +834,16 @@ .product-name { font-weight: 600; } - .stock-tag { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.18rem 0.55rem; - border-radius: 999px; - font-size: 0.82rem; - font-weight: 650; - line-height: 1.2; - background: #e8f1fc; - color: #0b5cad; + .col-dest { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; } - .stock-tag::before { - content: ''; - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background: currentColor; + .dest-detail { + font-size: 0.88rem; + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; } .col-packed { display: flex; @@ -868,13 +878,17 @@ font-weight: 650; white-space: nowrap; } - .pill-pass { + .pill-stock { + background: #e8f1fc; + color: #0b5cad; + } + .pill-order { background: var(--color-brand-tint); color: var(--color-success); } - .pill-attention { - background: #fbedcf; - color: #8a5a00; + .pill-split { + background: #f3e8fc; + color: #6b21a8; } .row-notes { grid-column: 1 / -1; @@ -888,7 +902,7 @@ /* ── Inline spreadsheet add row ───────────────────────────── */ .add-row { 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; align-items: start; padding: 1rem 1.5rem 1.1rem; @@ -947,35 +961,49 @@ color: var(--color-success); font-variant-numeric: tabular-nums; } - .qa-checks { + .add-dest { + gap: 0.4rem; + } + .dest-options { display: flex; - flex-direction: column; + flex-wrap: wrap; gap: 0.35rem; } - .qa-check { + .dest-toggle { display: inline-flex; align-items: center; - gap: 0.5rem; - font-size: 0.95rem; - font-weight: 500; - color: var(--color-text-primary); - line-height: 1.15; + gap: 0.4rem; + padding: 0.3rem 0.6rem; + border: 1px solid #aedcbb; + border-radius: 0.5rem; + background: var(--color-bg-surface); + font-size: 0.92rem; + font-weight: 600; + color: var(--color-text-secondary); cursor: pointer; user-select: none; } - .qa-check input { - width: 1.3rem; - height: 1.3rem; + .dest-toggle input { + width: 1.2rem; + height: 1.2rem; min-height: 0; margin: 0; flex-shrink: 0; accent-color: var(--color-brand); cursor: pointer; } - .qa-check input:focus-visible { + .dest-toggle input:focus-visible { outline: 2px solid var(--color-brand); 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 { justify-content: center; } @@ -1017,32 +1045,6 @@ gap: 1rem; 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 { padding: 0.25rem 0; background: none; @@ -1146,9 +1148,6 @@ border: 1px solid var(--color-border); border-radius: 0.7rem; } - .row.needs-attention { - border-color: #ecd9a8; - } .col-product, .row-notes { grid-column: 1 / -1; @@ -1161,7 +1160,7 @@ .col-product, .col-packed, .col-staff, - .col-qa { + .col-dest { display: flex; flex-direction: column; gap: 0.05rem; @@ -1180,6 +1179,7 @@ display: block; } .add-cell:nth-child(2), + .add-dest, .add-action { grid-column: 1 / -1; } diff --git a/frontend/src/routes/throughput/add/+page.svelte b/frontend/src/routes/throughput/add/+page.svelte index 8cde531..365ed26 100644 --- a/frontend/src/routes/throughput/add/+page.svelte +++ b/frontend/src/routes/throughput/add/+page.svelte @@ -7,6 +7,7 @@ ThroughputQuantityType } from '$lib/types'; import { CheckCircle2, AlertTriangle, ArrowLeft } from 'lucide-svelte'; + import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte'; let { data } = $props<{ data: { products: ThroughputProduct[] } }>(); const products = $derived(data.products ?? []); @@ -18,10 +19,10 @@ let bagSize = $state(''); let quantity = $state(''); let quantityType = $state('bags'); - let scalesChecked = $state(true); - let labelCorrect = $state(true); - let bagSealed = $state(true); - let palletGood = $state(true); + let forOrder = $state(false); + let forStock = $state(false); + let jobNumber = $state(''); + let stockQty = $state(''); let sampleBoxNo = $state(''); let tw1 = $state(''); let tw2 = $state(''); @@ -39,6 +40,8 @@ productId ? products.find((p: ThroughputProduct) => String(p.id) === productId) ?? null : null ); + const isSplit = $derived(forOrder && forStock); + $effect(() => { if (selectedProduct) { if (!bagSize && selectedProduct.default_bag_size != null) { @@ -50,8 +53,6 @@ } }); - const qaWarning = $derived(!(scalesChecked && labelCorrect && bagSealed && palletGood)); - function toNum(value: string): number | null { const trimmed = value.trim(); if (!trimmed) return null; @@ -64,10 +65,10 @@ bagSize = ''; quantity = ''; quantityType = 'bags'; - scalesChecked = true; - labelCorrect = true; - bagSealed = true; - palletGood = true; + forOrder = false; + forStock = false; + jobNumber = ''; + stockQty = ''; sampleBoxNo = ''; tw1 = ''; tw2 = ''; @@ -97,15 +98,37 @@ 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 { production_date: productionDate, product_id: Number(productId), product_name_snapshot: selectedProduct?.name ?? '', bag_size: bag, - scales_checked: scalesChecked, - label_correct: labelCorrect, - bag_sealed: bagSealed, - pallet_good_condition: palletGood, + for_order: forOrder, + for_stock: forStock, + job_number: forOrder ? job : null, + stock_quantity: stock, sample_box_no: sampleBoxNo.trim() || null, test_weight_1: toNum(tw1), test_weight_2: toNum(tw2), @@ -162,15 +185,10 @@ - +
+ Product * + +
-
- QA checklist - - - - - {#if qaWarning} -

One or more QA checks failed — this entry will be flagged.

- {/if} +
+ Where does this run go? +
+ + +
+
+ {#if forOrder} + + {/if} + {#if isSplit} + + {/if} +
@@ -308,25 +337,30 @@ font-weight: 600; 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; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 0.4rem 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.5rem 1rem; } .check { flex-direction: row; align-items: center; 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 { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); diff --git a/input_data/Operations Throughput.xlsx b/input_data/Operations Throughput.xlsx new file mode 100644 index 0000000..91f8993 Binary files /dev/null and b/input_data/Operations Throughput.xlsx differ