Files
Stackq/src/routes/items/ItemForm.svelte
T

323 lines
9.4 KiB
Svelte

<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { enhance } from "$app/forms";
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
import { UOM_OPTIONS, CATAGORY_OPTIONS, SUB_CATAGORY_OPTIONS } from "$lib/constants/items";
import type { StackqItem } from "$lib/types/items";
const COLLECTION = "Stackq_Items";
function itemImageUrl(recordId: string, filename: string): string {
return `${POCKETBASE_BASE_URL}/api/files/${COLLECTION}/${recordId}/${filename}`;
}
let {
item = null,
itemsForParent = [],
action,
submitLabel,
error,
onSuccess,
}: {
item?: StackqItem | null;
itemsForParent?: { id: string; Item: string; SKU: string }[];
action: string;
submitLabel: string;
error?: string | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const parentOptions = $derived(
item ? itemsForParent.filter((p) => p.id !== item!.id) : itemsForParent
);
const defaultItem = $derived(item?.Item ?? "");
const defaultSku = $derived(item?.SKU ?? "");
const defaultDesc = $derived(item?.Description ?? "");
const defaultUom = $derived(item?.UOM ?? "");
const defaultCost = $derived(item?.Cost != null ? String(item.Cost) : "");
const defaultVendor = $derived(item?.Vendor ?? "");
const defaultCat = $derived(Array.isArray(item?.Catagory) ? item!.Catagory[0] : item?.Catagory ?? "");
const defaultSub = $derived(Array.isArray(item?.Sub_Catagory) ? item!.Sub_Catagory[0] : item?.Sub_Catagory ?? "");
const defaultParent = $derived(item?.Parent ?? "");
const defaultHasParent = $derived(!!item?.Has_Parent);
const defaultStock = $derived(!!item?.Stock);
let skuValue = $state("");
let showScanner = $state(false);
let scanError = $state<string | null>(null);
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
let stream: MediaStream | null = null;
let scanRAF = 0;
$effect(() => {
skuValue = defaultSku;
});
async function ensureBarcodeDetector(): Promise<boolean> {
if (typeof globalThis === "undefined" || "BarcodeDetector" in globalThis) return true;
try {
const mod = await import("barcode-detector-api-polyfill");
(globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector =
mod.BarcodeDetector;
return true;
} catch {
return false;
}
}
async function startScan() {
scanError = null;
const hasDetector = await ensureBarcodeDetector();
if (!hasDetector) {
scanError = "Failed to load barcode scanner. Try again or use Chrome/Edge.";
showScanner = true;
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
showScanner = true;
} catch (e) {
scanError = e instanceof Error ? e.message : "Could not access camera.";
showScanner = true;
}
}
$effect(() => {
if (!showScanner || !stream || !videoEl) return;
videoEl.srcObject = stream;
videoEl.play().then(() => scanLoop()).catch(() => {});
return () => {
if (videoEl.srcObject) videoEl.srcObject = null;
};
});
const SCAN_INTERVAL_MS = 300;
function scanLoop() {
if (!showScanner || !videoEl || !("BarcodeDetector" in globalThis)) return;
const detector = new (globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector();
let lastScan = 0;
async function detect() {
if (!showScanner || !videoEl) return;
if (videoEl.readyState < 2) {
scanRAF = requestAnimationFrame(detect);
return;
}
const now = Date.now();
if (now - lastScan >= SCAN_INTERVAL_MS) {
lastScan = now;
try {
const barcodes = await detector.detect(videoEl);
if (barcodes.length > 0) {
skuValue = barcodes[0].rawValue;
stopScan();
return;
}
} catch {
// ignore frame errors
}
}
scanRAF = requestAnimationFrame(detect);
}
detect();
}
function stopScan() {
showScanner = false;
scanError = null;
if (scanRAF) {
cancelAnimationFrame(scanRAF);
scanRAF = 0;
}
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
if (videoEl && videoEl.srcObject) {
videoEl.srcObject = null;
}
}
</script>
<form
method="POST"
action={action}
enctype="multipart/form-data"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === "success" && result.data?.success) {
onSuccess?.();
}
}}
class="flex flex-col gap-4"
>
{#if isEdit}
<input type="hidden" name="id" value={item!.id} />
{/if}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="Item">Item</Label>
<Input id="Item" name="Item" value={defaultItem} required />
</div>
<div class="space-y-2">
<Label for="SKU">SKU</Label>
<div class="flex gap-2">
<Input id="SKU" name="SKU" bind:value={skuValue} class="flex-1" />
<Button type="button" variant="outline" onclick={startScan} title="Scan barcode">Scan</Button>
</div>
</div>
</div>
{#if showScanner}
<div
class="bg-background/95 fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 p-4 backdrop-blur-sm"
role="dialog"
aria-label="Scan barcode"
>
<div class="flex w-full max-w-sm flex-col gap-2">
{#if scanError}
<p class="text-destructive text-sm">{scanError}</p>
{:else}
<video
bind:this={videoEl}
class="border-input w-full rounded-lg border"
muted
playsinline
></video>
{/if}
<div class="flex gap-2">
<Button type="button" variant="outline" class="flex-1" onclick={stopScan}>
Cancel
</Button>
</div>
</div>
</div>
{/if}
<div class="space-y-2">
<Label for="Description">Description</Label>
<Input id="Description" name="Description" value={defaultDesc} />
</div>
<div class="space-y-2">
<Label for="Image">Image</Label>
{#if isEdit && item?.Image}
<div class="flex flex-wrap items-center gap-3">
<img
src={itemImageUrl(item!.id, item!.Image)}
alt=""
class="size-20 rounded border object-cover"
/>
<span class="text-muted-foreground text-sm">Replace:</span>
</div>
{/if}
<Input id="Image" name="Image" type="file" accept="image/*" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="UOM">UOM</Label>
<select
id="UOM"
name="UOM"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each UOM_OPTIONS as o}
<option value={o} selected={defaultUom === o}>{o}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="Cost">Cost</Label>
<Input id="Cost" name="Cost" type="number" step="any" value={defaultCost} />
</div>
</div>
<div class="space-y-2">
<Label for="Vendor">Vendor</Label>
<Input id="Vendor" name="Vendor" value={defaultVendor} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="Catagory">Catagory</Label>
<select
id="Catagory"
name="Catagory"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each CATAGORY_OPTIONS as o}
<option value={o} selected={defaultCat === o}>{o}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="Sub_Catagory">Sub_Catagory</Label>
<select
id="Sub_Catagory"
name="Sub_Catagory"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each SUB_CATAGORY_OPTIONS as o}
<option value={o} selected={defaultSub === o}>{o}</option>
{/each}
</select>
</div>
</div>
<div class="space-y-2">
<Label for="Parent">Parent</Label>
<select
id="Parent"
name="Parent"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each parentOptions as p}
<option value={p.id} selected={defaultParent === p.id}>
{p.Item || p.SKU || p.id}
</option>
{/each}
</select>
</div>
<div class="flex flex-wrap gap-6">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="Has_Parent"
value="on"
checked={defaultHasParent}
class="border-input size-4 rounded"
/>
Has Parent
</label>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="Stock"
value="on"
checked={defaultStock}
class="border-input size-4 rounded"
/>
Stock
</label>
</div>
{#if error}
<p class="text-destructive text-sm">{error}</p>
{/if}
<div class="flex justify-end gap-2">
<Button type="submit">{submitLabel}</Button>
</div>
</form>