539 lines
12 KiB
Svelte
539 lines
12 KiB
Svelte
<script lang="ts">
|
||||
|
|
import { Plus, TriangleAlert, X } from 'lucide-svelte';
|
|||
|
|
|
|||
|
|
import ThroughputProductPicker from '$lib/components/throughput/ThroughputProductPicker.svelte';
|
|||
|
|
import type { ThroughputProduct, ThroughputQuantityType } from '$lib/types';
|
|||
|
|
|
|||
|
|
let {
|
|||
|
|
products,
|
|||
|
|
editingId,
|
|||
|
|
saving,
|
|||
|
|
nDate = $bindable(''),
|
|||
|
|
nProductId = $bindable(''),
|
|||
|
|
nQuantity = $bindable(''),
|
|||
|
|
nType = $bindable<ThroughputQuantityType>('bags'),
|
|||
|
|
nBagSize = $bindable(''),
|
|||
|
|
nStaff = $bindable(''),
|
|||
|
|
nNotes = $bindable(''),
|
|||
|
|
nForOrder = $bindable(false),
|
|||
|
|
nForStock = $bindable(false),
|
|||
|
|
nJobNumber = $bindable(''),
|
|||
|
|
nStockQty = $bindable(''),
|
|||
|
|
showNote = $bindable(false),
|
|||
|
|
addError,
|
|||
|
|
isSplit,
|
|||
|
|
addTotalKg,
|
|||
|
|
formatNumber,
|
|||
|
|
onSubmit,
|
|||
|
|
onDismissNote,
|
|||
|
|
onCancelEdit,
|
|||
|
|
composerRef = $bindable<HTMLElement | null>(null)
|
|||
|
|
}: {
|
|||
|
|
products: ThroughputProduct[];
|
|||
|
|
editingId: number | null;
|
|||
|
|
saving: boolean;
|
|||
|
|
nDate?: string;
|
|||
|
|
nProductId?: string;
|
|||
|
|
nQuantity?: string;
|
|||
|
|
nType?: ThroughputQuantityType;
|
|||
|
|
nBagSize?: string;
|
|||
|
|
nStaff?: string;
|
|||
|
|
nNotes?: string;
|
|||
|
|
nForOrder?: boolean;
|
|||
|
|
nForStock?: boolean;
|
|||
|
|
nJobNumber?: string;
|
|||
|
|
nStockQty?: string;
|
|||
|
|
showNote?: boolean;
|
|||
|
|
addError: string;
|
|||
|
|
isSplit: boolean;
|
|||
|
|
addTotalKg: number | null;
|
|||
|
|
formatNumber: (value: number | null | undefined, digits?: number) => string;
|
|||
|
|
onSubmit: () => void;
|
|||
|
|
onDismissNote: () => void;
|
|||
|
|
onCancelEdit: () => void;
|
|||
|
|
composerRef?: HTMLElement | null;
|
|||
|
|
} = $props();
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<div class="composer" class:editing={editingId != null} bind:this={composerRef}>
|
|||
|
|
<div class="composer-head">
|
|||
|
|
<div class="composer-title">
|
|||
|
|
<h2>{editingId != null ? 'Edit packing run' : 'Add a packing run'}</h2>
|
|||
|
|
</div>
|
|||
|
|
{#if editingId != null}
|
|||
|
|
<button type="button" class="cancel-edit" onclick={onCancelEdit}>
|
|||
|
|
<X size={16} strokeWidth={2.4} /> Cancel edit
|
|||
|
|
</button>
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<form class="add-row" onsubmit={(e) => { e.preventDefault(); onSubmit(); }}>
|
|||
|
|
<div class="add-cell">
|
|||
|
|
<span class="cell-label">Date</span>
|
|||
|
|
<input type="date" bind:value={nDate} aria-label="Production date" />
|
|||
|
|
</div>
|
|||
|
|
<div class="add-cell add-product">
|
|||
|
|
<span class="cell-label">Product</span>
|
|||
|
|
<ThroughputProductPicker {products} bind:productId={nProductId} inputId="throughput-add-product" />
|
|||
|
|
</div>
|
|||
|
|
<div class="add-cell">
|
|||
|
|
<span class="cell-label">Packed</span>
|
|||
|
|
<div class="packed-inputs">
|
|||
|
|
<input
|
|||
|
|
class="qty"
|
|||
|
|
type="number"
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
inputmode="decimal"
|
|||
|
|
bind:value={nQuantity}
|
|||
|
|
placeholder={nType === 'bags' ? 'Bags' : 'Total kg'}
|
|||
|
|
aria-label={nType === 'bags' ? 'Number of bags' : 'Total kilograms'}
|
|||
|
|
/>
|
|||
|
|
<select class="unit" bind:value={nType} aria-label="Bags or kilograms">
|
|||
|
|
<option value="bags">bags</option>
|
|||
|
|
<option value="kg">kg (bulka)</option>
|
|||
|
|
</select>
|
|||
|
|
{#if nType === 'bags'}
|
|||
|
|
<span class="times" aria-hidden="true">×</span>
|
|||
|
|
<input
|
|||
|
|
class="bag"
|
|||
|
|
type="number"
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
inputmode="decimal"
|
|||
|
|
bind:value={nBagSize}
|
|||
|
|
placeholder="kg/bag"
|
|||
|
|
aria-label="Kilograms per bag"
|
|||
|
|
/>
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
{#if nType === 'bags' && addTotalKg !== null}
|
|||
|
|
<span class="packed-total">= {formatNumber(addTotalKg)} kg total</span>
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
<div class="add-cell">
|
|||
|
|
<span class="cell-label">Packed by</span>
|
|||
|
|
<input type="text" bind:value={nStaff} placeholder="Name" aria-label="Packed by" />
|
|||
|
|
</div>
|
|||
|
|
<div class="add-cell add-dest">
|
|||
|
|
<span class="cell-label">Destination</span>
|
|||
|
|
<div class="dest-rows">
|
|||
|
|
<div class="dest-line">
|
|||
|
|
<label class="dest-toggle" class:on={nForOrder}>
|
|||
|
|
<input type="checkbox" bind:checked={nForOrder} /> For an order
|
|||
|
|
</label>
|
|||
|
|
{#if nForOrder}
|
|||
|
|
<input
|
|||
|
|
class="dest-input"
|
|||
|
|
type="text"
|
|||
|
|
bind:value={nJobNumber}
|
|||
|
|
placeholder="Job number (Order Circle)"
|
|||
|
|
aria-label="Job number"
|
|||
|
|
/>
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
<div class="dest-line">
|
|||
|
|
<label class="dest-toggle" class:on={nForStock}>
|
|||
|
|
<input type="checkbox" bind:checked={nForStock} /> For stock
|
|||
|
|
</label>
|
|||
|
|
{#if isSplit}
|
|||
|
|
<input
|
|||
|
|
class="dest-input"
|
|||
|
|
type="number"
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
inputmode="decimal"
|
|||
|
|
bind:value={nStockQty}
|
|||
|
|
placeholder={`To stock (${nType === 'bags' ? 'bags' : 'kg'})`}
|
|||
|
|
aria-label="Amount going to stock"
|
|||
|
|
/>
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="add-cell add-action">
|
|||
|
|
<button type="submit" class="add-entry-button" disabled={saving}>
|
|||
|
|
<Plus size={18} strokeWidth={2.6} />
|
|||
|
|
<span>{saving ? 'Saving…' : editingId != null ? 'Save' : 'Add'}</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="add-extra">
|
|||
|
|
{#if showNote}
|
|||
|
|
<div class="note-field">
|
|||
|
|
<input
|
|||
|
|
class="note-input"
|
|||
|
|
type="text"
|
|||
|
|
bind:value={nNotes}
|
|||
|
|
placeholder="Note (optional)"
|
|||
|
|
aria-label="Note"
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="note-dismiss"
|
|||
|
|
title="Remove note"
|
|||
|
|
aria-label="Remove note"
|
|||
|
|
onclick={onDismissNote}
|
|||
|
|
>
|
|||
|
|
<X size={16} strokeWidth={2.4} />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
{:else}
|
|||
|
|
<button type="button" class="link-button" onclick={() => (showNote = true)}>+ Add a note</button>
|
|||
|
|
{/if}
|
|||
|
|
{#if addError}
|
|||
|
|
<span class="add-error"><TriangleAlert size={15} strokeWidth={2.4} /> {addError}</span>
|
|||
|
|
{/if}
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<style>
|
|||
|
|
.composer {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0.85rem;
|
|||
|
|
border: 1px solid color-mix(in srgb, var(--color-brand) 16%, var(--color-border));
|
|||
|
|
border-radius: 1rem;
|
|||
|
|
background: var(--color-bg-surface);
|
|||
|
|
box-shadow: 0 16px 34px -28px rgba(15, 23, 42, 0.28);
|
|||
|
|
/* The product picker menu is absolutely positioned and needs to escape the
|
|||
|
|
card bounds without being cut off. */
|
|||
|
|
overflow: visible;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.composer-head {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 1rem;
|
|||
|
|
padding: 1rem 1.45rem 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.composer-title h2 {
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 1.22rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
letter-spacing: -0.02em;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-row {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 7.5rem minmax(19rem, 1.7fr) minmax(14rem, 1.15fr) minmax(7rem, 0.65fr) minmax(14rem, 1.2fr) auto;
|
|||
|
|
gap: 0.75rem 0.85rem;
|
|||
|
|
align-items: start;
|
|||
|
|
padding: 0 1.45rem 1.25rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-cell {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 0.3rem;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-row input:not([type='checkbox']),
|
|||
|
|
.add-row select {
|
|||
|
|
width: 100%;
|
|||
|
|
min-height: 48px;
|
|||
|
|
padding: 0.62rem 0.78rem;
|
|||
|
|
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
|||
|
|
border-radius: 0.8rem;
|
|||
|
|
font-size: 0.98rem;
|
|||
|
|
color: var(--color-text-primary);
|
|||
|
|
background: var(--color-bg-surface);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-row input:not([type='checkbox']):focus-visible,
|
|||
|
|
.add-row select:focus-visible,
|
|||
|
|
.cancel-edit:focus-visible,
|
|||
|
|
.add-entry-button:focus-visible,
|
|||
|
|
.note-dismiss:focus-visible {
|
|||
|
|
outline: 3px solid var(--color-brand);
|
|||
|
|
outline-offset: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.packed-inputs {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.packed-inputs .qty {
|
|||
|
|
flex: 1 1 4.5rem;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.packed-inputs .unit {
|
|||
|
|
flex: 0 0 4.7rem;
|
|||
|
|
width: 4.7rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.packed-inputs .bag {
|
|||
|
|
flex: 0 0 6rem;
|
|||
|
|
width: 6rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.packed-inputs .times {
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
color: var(--color-text-secondary);
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.packed-total {
|
|||
|
|
margin-top: 0.3rem;
|
|||
|
|
font-size: 0.84rem;
|
|||
|
|
font-weight: 650;
|
|||
|
|
color: var(--color-success);
|
|||
|
|
font-variant-numeric: tabular-nums;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-dest {
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dest-rows {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dest-line {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dest-line .dest-toggle {
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
min-width: 8.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dest-line .dest-input {
|
|||
|
|
flex: 1 1 auto;
|
|||
|
|
min-width: 0;
|
|||
|
|
width: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dest-toggle {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
padding: 0.4rem 0.68rem;
|
|||
|
|
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
|
|||
|
|
border-radius: 0.72rem;
|
|||
|
|
background: var(--color-bg-surface);
|
|||
|
|
font-size: 0.88rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--color-text-secondary);
|
|||
|
|
cursor: pointer;
|
|||
|
|
user-select: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dest-toggle input {
|
|||
|
|
width: 1.2rem;
|
|||
|
|
height: 1.2rem;
|
|||
|
|
min-height: 0;
|
|||
|
|
margin: 0;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
accent-color: var(--color-brand);
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dest-toggle.on {
|
|||
|
|
border-color: var(--color-brand);
|
|||
|
|
background: var(--color-brand-tint);
|
|||
|
|
color: var(--color-success);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dest-input {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-action {
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-entry-button {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
min-height: 48px;
|
|||
|
|
padding: 0.62rem 1.28rem;
|
|||
|
|
background: var(--color-brand);
|
|||
|
|
color: #fff;
|
|||
|
|
border: 1px solid var(--color-brand);
|
|||
|
|
border-radius: 0.8rem;
|
|||
|
|
font-size: 0.98rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: background-color 160ms ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-entry-button:hover:not(:disabled) {
|
|||
|
|
background: #126a33;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-entry-button:disabled {
|
|||
|
|
opacity: 0.65;
|
|||
|
|
cursor: progress;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-extra {
|
|||
|
|
grid-column: 1 / -1;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 1rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
padding-top: 0.15rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.link-button {
|
|||
|
|
padding: 0.25rem 0;
|
|||
|
|
background: none;
|
|||
|
|
border: 0;
|
|||
|
|
color: var(--color-success);
|
|||
|
|
font-size: 0.95rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.link-button:hover {
|
|||
|
|
text-decoration: underline;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.note-field {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
flex: 1 1 16rem;
|
|||
|
|
max-width: 28rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.note-input {
|
|||
|
|
flex: 1 1 auto;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.note-dismiss,
|
|||
|
|
.cancel-edit {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
border: 1px solid var(--color-border);
|
|||
|
|
background: var(--color-bg-surface);
|
|||
|
|
color: var(--color-text-secondary);
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.note-dismiss {
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
width: 2.1rem;
|
|||
|
|
height: 2.1rem;
|
|||
|
|
padding: 0;
|
|||
|
|
border-radius: 0.55rem;
|
|||
|
|
transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cancel-edit {
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
padding: 0.5rem 0.85rem;
|
|||
|
|
border-radius: 0.6rem;
|
|||
|
|
font-size: 0.92rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.note-dismiss:hover,
|
|||
|
|
.cancel-edit:hover {
|
|||
|
|
color: var(--color-text-primary);
|
|||
|
|
border-color: var(--color-text-muted);
|
|||
|
|
background: #fafbfc;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-error {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
color: #8a1622;
|
|||
|
|
font-weight: 650;
|
|||
|
|
font-size: 0.95rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cell-label {
|
|||
|
|
display: none;
|
|||
|
|
font-size: 0.85rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--color-text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.composer.editing {
|
|||
|
|
border-color: var(--color-brand);
|
|||
|
|
box-shadow: 0 0 0 1px var(--color-brand) inset;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 1440px) {
|
|||
|
|
.add-row {
|
|||
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|||
|
|
gap: 1rem 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-action {
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-entry-button {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 1040px) {
|
|||
|
|
.add-row {
|
|||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|||
|
|
gap: 0.9rem 1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-cell:nth-child(2),
|
|||
|
|
.add-cell:nth-child(3),
|
|||
|
|
.add-dest,
|
|||
|
|
.add-action {
|
|||
|
|
grid-column: 1 / -1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-entry-button {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 760px) {
|
|||
|
|
.composer-head {
|
|||
|
|
padding-left: 1.15rem;
|
|||
|
|
padding-right: 1.15rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.composer-title h2 {
|
|||
|
|
font-size: 1.28rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-row {
|
|||
|
|
grid-template-columns: 1fr 1fr;
|
|||
|
|
gap: 0.85rem 1rem;
|
|||
|
|
padding: 0 1.15rem 1.15rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-cell .cell-label {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-cell:nth-child(2),
|
|||
|
|
.add-dest,
|
|||
|
|
.add-action {
|
|||
|
|
grid-column: 1 / -1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.add-entry-button {
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|