Dockerfile updates
This commit is contained in:
@@ -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;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user