323 lines
9.4 KiB
Svelte
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>
|