feat: Add SvelteKit web app with scan sessions and import queue
Replaces the Flask/Alpine web app with a SvelteKit 2 + Svelte 5 rewrite under web/, built on adapter-node and Tailwind v4. Same shape as the reference b4l budget app — no auth, stateless pass-through to InvenTree. New "scan session" flow groups mass scans into a session with live counters (scanned / succeeded / pending / failed). Unknown parts in import mode are fed to a worker pool that spawns inventree-part-import (IMPORT_CONCURRENCY, default 3, with 3-retry). Anything that can't be resolved automatically — parse errors, missing qty, invalid location, API errors, or imports that exhaust retries — drops into a Failures panel with a per-item Fix dialog (edit fields / search existing part / retry import). CSV export on the failure list. Layout is two-column on lg+: scanner + activity on the left, pending imports + failures on the right. Light-theme default. SSE on /api/events streams session and import events to the client. Barcode parser ported from the Python/JS versions and hardened for Digi-Key MH10.8.2 barcodes both with and without GS separators (old parser greedy-matched Q's digits and read "Q6" + "11ZPICK" as 611). Import worker also now treats a subprocess failure followed by a successful findPart as a success, so partial imports (part created but a duplicate parameter trips the DB constraint) no longer land in the Failures panel. Deploy artifacts: systemd unit, nginx example (SSE-friendly), and a step-by-step deploy/README. Requires inventree-part-import >= 1.9.2 on the server for InvenTree 1.x API compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,16 @@
|
|||||||
"Bash(python:*)",
|
"Bash(python:*)",
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(uv:*)",
|
"Bash(uv:*)",
|
||||||
"Bash(tree:*)"
|
"Bash(tree:*)",
|
||||||
|
"WebFetch(domain:git.b4l.co.th)",
|
||||||
|
"mcp__svelte__get-documentation",
|
||||||
|
"mcp__svelte__svelte-autofixer",
|
||||||
|
"Read(//c/dev/inventree-stock-tool/web/**)",
|
||||||
|
"Bash(npm install *)",
|
||||||
|
"Bash(npx svelte-kit *)",
|
||||||
|
"Bash(npx svelte-check *)",
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(npx tsx *)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# InvenTree server (no trailing slash)
|
||||||
|
INVENTREE_HOST=https://your-inventree-server.example.com
|
||||||
|
INVENTREE_TOKEN=your-api-token-here
|
||||||
|
|
||||||
|
# Port adapter-node listens on (bound to 127.0.0.1 via HOST)
|
||||||
|
PORT=3000
|
||||||
|
HOST=127.0.0.1
|
||||||
|
ORIGIN=https://stock-tool.your-domain.example.com
|
||||||
|
|
||||||
|
# Max concurrent `inventree-part-import` processes
|
||||||
|
IMPORT_CONCURRENCY=3
|
||||||
|
|
||||||
|
# Absolute path to inventree-part-import binary. Leave empty to resolve from PATH.
|
||||||
|
INVENTREE_PART_IMPORT_BIN=
|
||||||
|
|
||||||
|
# Per-import timeout, seconds
|
||||||
|
IMPORT_TIMEOUT_SEC=60
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# InvenTree Stock Tool — Web (SvelteKit)
|
||||||
|
|
||||||
|
SvelteKit rewrite of the Flask/Alpine web app.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- SvelteKit 2 + Svelte 5 (runes) + adapter-node
|
||||||
|
- Tailwind CSS v4
|
||||||
|
- In-process scan-session store (no database)
|
||||||
|
- Server-Sent Events for live session/import updates
|
||||||
|
- Shell-out to `inventree-part-import` for unknown-part creation
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env to point at your InvenTree server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:5173.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
node build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature: Scan Session
|
||||||
|
|
||||||
|
- A session groups a run of scans. Every scan goes through `/api/session/scan`
|
||||||
|
and is recorded in the session's stats.
|
||||||
|
- Unknown parts in "Add Stock" mode are enqueued for `inventree-part-import`;
|
||||||
|
workers run in parallel (tunable via `IMPORT_CONCURRENCY`, default 3).
|
||||||
|
- Failures (unknown parts outside import mode, missing qty, invalid location,
|
||||||
|
API errors, and imports that exhaust 3 retries) land in the Failures panel.
|
||||||
|
- Each failure has a **Fix** dialog — override the part code / quantity /
|
||||||
|
location, or search for an existing part and link it instead. Retry feeds
|
||||||
|
back through the same code path.
|
||||||
|
- Export CSV of failures for offline follow-up.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── src/
|
||||||
|
│ ├── app.css, app.html, app.d.ts
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── barcode.ts shared parse_scan + commands
|
||||||
|
│ │ ├── client.ts fetch wrappers for API routes
|
||||||
|
│ │ ├── types.ts shared types
|
||||||
|
│ │ ├── server/ ← imported only from +server.ts / hooks
|
||||||
|
│ │ │ ├── env.ts typed env config
|
||||||
|
│ │ │ ├── inventree.ts 8 API helpers
|
||||||
|
│ │ │ ├── events.ts SSE pubsub
|
||||||
|
│ │ │ ├── importQueue.ts N-worker pool → inventree-part-import
|
||||||
|
│ │ │ └── sessions.ts in-memory scan sessions
|
||||||
|
│ │ ├── stores/app.svelte.ts
|
||||||
|
│ │ └── components/…
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── +layout.svelte, +page.svelte
|
||||||
|
│ └── api/
|
||||||
|
│ ├── config, locations, proxy/image, part/search, events
|
||||||
|
│ └── session/{start,end,mode,location,scan,retry,failures}
|
||||||
|
└── deploy/ systemd unit, nginx example, deploy README
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See [`deploy/README.md`](deploy/README.md).
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
Deploy target: Linux server, Node 20+, nginx fronting `adapter-node` on 127.0.0.1:3000, systemd managing the process. Python 3.10+ with `inventree-part-import` installed alongside Node — we shell out to it for unknown parts.
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser --system --group --home /opt/inventree-stock-tool stock-tool
|
||||||
|
sudo chown -R stock-tool:stock-tool /opt/inventree-stock-tool
|
||||||
|
|
||||||
|
# As the service user:
|
||||||
|
sudo -u stock-tool -H bash
|
||||||
|
cd /opt/inventree-stock-tool
|
||||||
|
|
||||||
|
# Python side
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install "inventree-part-import>=1.9.2" # 1.9.2+ required for InvenTree 1.x API
|
||||||
|
# configure inventree-part-import's suppliers at
|
||||||
|
# ~/.config/inventree-part-import/ (Digi-Key, Mouser, LCSC API keys)
|
||||||
|
|
||||||
|
# Node side — pushed via git or rsync into /opt/inventree-stock-tool
|
||||||
|
npm ci --omit=dev
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Env
|
||||||
|
cp .env.example .env
|
||||||
|
$EDITOR .env
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` must contain at minimum `INVENTREE_HOST`, `INVENTREE_TOKEN`, and
|
||||||
|
`INVENTREE_PART_IMPORT_BIN=/opt/inventree-stock-tool/.venv/bin/inventree-part-import`.
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/inventree-stock-tool.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now inventree-stock-tool
|
||||||
|
sudo systemctl status inventree-stock-tool
|
||||||
|
journalctl -u inventree-stock-tool -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust `User=`, `Group=`, `WorkingDirectory=` in the unit file to match your
|
||||||
|
setup. The unit pins `INVENTREE_PART_IMPORT_BIN` via `.env` — the `PATH=` line
|
||||||
|
in the unit is a fallback for when the binary is on a regular `bin` directory.
|
||||||
|
|
||||||
|
## nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/stock-tool
|
||||||
|
sudo ln -s /etc/nginx/sites-available/stock-tool /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Then issue a cert:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d stock-tool.your-domain.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# on build server / laptop:
|
||||||
|
rsync -az --delete --exclude=node_modules --exclude=build \
|
||||||
|
./web/ stock-tool@host:/opt/inventree-stock-tool/
|
||||||
|
|
||||||
|
# on server:
|
||||||
|
sudo -u stock-tool -H bash
|
||||||
|
cd /opt/inventree-stock-tool
|
||||||
|
npm ci --omit=dev
|
||||||
|
npm run build
|
||||||
|
exit
|
||||||
|
sudo systemctl restart inventree-stock-tool
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tuning
|
||||||
|
|
||||||
|
- `IMPORT_CONCURRENCY` (default 3) — number of concurrent `inventree-part-import`
|
||||||
|
subprocesses. Bump up only if supplier APIs aren't rate-limiting you and
|
||||||
|
InvenTree handles concurrent category/manufacturer writes cleanly. 3–4 is
|
||||||
|
typically the sweet spot; higher values tend to produce 429s rather than
|
||||||
|
speedups.
|
||||||
|
- `IMPORT_TIMEOUT_SEC` (default 60) — per-attempt timeout. Raise if your
|
||||||
|
supplier API is slow or the server cold-starts imports.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=InvenTree Stock Tool (SvelteKit)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/opt/inventree-stock-tool
|
||||||
|
EnvironmentFile=/opt/inventree-stock-tool/.env
|
||||||
|
# Prepend user-site bin so `inventree-part-import` installed via `pip install --user`
|
||||||
|
# is on PATH. Adjust the path if you install it into a venv instead.
|
||||||
|
Environment=PATH=/opt/inventree-stock-tool/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/usr/bin/node build
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Basic hardening
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/opt/inventree-stock-tool
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name stock-tool.your-domain.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/stock-tool.your-domain.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/stock-tool.your-domain.example.com/privkey.pem;
|
||||||
|
|
||||||
|
# SvelteKit / adapter-node backend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Streaming (SSE) needs these off/long
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 24h;
|
||||||
|
proxy_send_timeout 24h;
|
||||||
|
|
||||||
|
# WebSocket-style upgrades (not used currently, kept for future)
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name stock-tool.your-domain.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
Generated
+2481
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "inventree-stock-tool-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "node build",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.2.11",
|
||||||
|
"@sveltejs/kit": "^2.15.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"svelte": "^5.15.0",
|
||||||
|
"svelte-check": "^4.1.1",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { parseScan } from '../src/lib/barcode.ts';
|
||||||
|
|
||||||
|
const GS = '\x1D';
|
||||||
|
const RS = '\x1E';
|
||||||
|
|
||||||
|
// User's Digi-Key barcode as the scanner actually delivers it (with GS between
|
||||||
|
// fields and the RS format-envelope after `[)>`).
|
||||||
|
const digiKey = [
|
||||||
|
'[)>',
|
||||||
|
RS,
|
||||||
|
'06',
|
||||||
|
GS,
|
||||||
|
'P2073-USB1035-GF-P-0-B-B-ND',
|
||||||
|
GS,
|
||||||
|
'1PUSB1035-GF-P-0-B-B',
|
||||||
|
GS,
|
||||||
|
'30P2073-USB1035-GF-P-0-B-B-ND',
|
||||||
|
GS,
|
||||||
|
'K',
|
||||||
|
GS,
|
||||||
|
'1K9867254010',
|
||||||
|
GS,
|
||||||
|
'10K1242607169',
|
||||||
|
GS,
|
||||||
|
'D25511',
|
||||||
|
GS,
|
||||||
|
'T251215-501',
|
||||||
|
GS,
|
||||||
|
'11K1',
|
||||||
|
GS,
|
||||||
|
'4LCN',
|
||||||
|
GS,
|
||||||
|
'Q6',
|
||||||
|
GS,
|
||||||
|
'11ZPICK',
|
||||||
|
GS,
|
||||||
|
'12Z1064967213',
|
||||||
|
GS,
|
||||||
|
'13Z9999992',
|
||||||
|
GS,
|
||||||
|
'20Z' + '0'.repeat(55)
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
name: "Digi-Key MH10.8.2 with GS separators (qty=6)",
|
||||||
|
raw: digiKey,
|
||||||
|
expected: { partCode: '2073-USB1035-GF-P-0-B-B-ND', quantity: 6 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same barcode but scanner stripped all separators (fallback)",
|
||||||
|
raw: '[)>06P2073-USB1035-GF-P-0-B-B-ND1PUSB1035-GF-P-0-B-B30P2073-USB1035-GF-P-0-B-B-NDK1K9867254010K1242607169D25511T251215-50111K14LCNQ611ZPICK12Z1064967213Z99999920Z0000000000000000000000000000000000000000000000000000000',
|
||||||
|
expected: { partCode: '2073-USB1035-GF-P-0-B-B-ND', quantity: 6 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GS-separated without MH10.8.2 envelope',
|
||||||
|
raw: `30PABC${GS}Q15`,
|
||||||
|
expected: { partCode: 'ABC', quantity: 15 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Quantity legitimately 611 before 11Z (fallback path)',
|
||||||
|
raw: '[)>06PABC1PXYZQ61111ZPICK',
|
||||||
|
expected: { partCode: 'ABC', quantity: 611 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MH10.8.2 with separators, only 1P present',
|
||||||
|
raw: `[)>06${GS}1PMYPART${GS}Q42`,
|
||||||
|
expected: { partCode: 'MYPART', quantity: 42 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'JSON-like',
|
||||||
|
raw: '{PM:WIDGET-42,QTY:7}',
|
||||||
|
expected: { partCode: 'WIDGET-42', quantity: 7 }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
for (const c of cases) {
|
||||||
|
const got = parseScan(c.raw);
|
||||||
|
const ok =
|
||||||
|
got.partCode === c.expected.partCode && got.quantity === c.expected.quantity;
|
||||||
|
if (ok) {
|
||||||
|
pass++;
|
||||||
|
console.log(`ok ${c.name}`);
|
||||||
|
} else {
|
||||||
|
fail++;
|
||||||
|
console.log(`FAIL ${c.name}`);
|
||||||
|
console.log(` got ${JSON.stringify(got)}`);
|
||||||
|
console.log(` expected ${JSON.stringify(c.expected)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${pass}/${pass + fail} pass`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-brand-500: oklch(0.55 0.15 240);
|
||||||
|
--color-brand-600: oklch(0.48 0.16 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>InvenTree Stock Tool</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover" class="min-h-screen bg-slate-50 text-slate-900">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { ParsedBarcode } from './types';
|
||||||
|
|
||||||
|
const SEP_RE = /[\x1D\x1E]/g;
|
||||||
|
const SEP_RE_TEST = /[\x1D\x1E]/;
|
||||||
|
|
||||||
|
// Matches the MH10.8.2 envelope `[)>06` with an optional RS after `[)>` and
|
||||||
|
// any trailing GS/RS separators.
|
||||||
|
const MH10_ENVELOPE = /^\[\)>\x1E?06[\x1D\x1E]*/;
|
||||||
|
|
||||||
|
// DIs that commonly follow `Q`. Used only when the scanner stripped the GS/RS
|
||||||
|
// separators and we have to re-tokenize a continuous string.
|
||||||
|
const DIS_AFTER_Q = [
|
||||||
|
'11Z',
|
||||||
|
'12Z',
|
||||||
|
'13Z',
|
||||||
|
'14Z',
|
||||||
|
'20Z',
|
||||||
|
'21Z',
|
||||||
|
'1Z',
|
||||||
|
'1T',
|
||||||
|
'2T',
|
||||||
|
'3T',
|
||||||
|
'1L',
|
||||||
|
'4L',
|
||||||
|
'14L'
|
||||||
|
];
|
||||||
|
|
||||||
|
function cleanPartCode(code: string): string {
|
||||||
|
let out = '';
|
||||||
|
for (const ch of code) {
|
||||||
|
const c = ch.charCodeAt(0);
|
||||||
|
if (c >= 32 && c <= 126) out += ch;
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonBraces(raw: string): ParsedBarcode {
|
||||||
|
const content = raw.trim().slice(1, -1);
|
||||||
|
let partCode: string | null = null;
|
||||||
|
let quantity: number | null = null;
|
||||||
|
for (const kv of content.split(',')) {
|
||||||
|
if (!kv.includes(':')) continue;
|
||||||
|
const [k, ...rest] = kv.split(':');
|
||||||
|
const v = rest.join(':');
|
||||||
|
const key = k.trim().toUpperCase();
|
||||||
|
const val = v.trim();
|
||||||
|
if (key === 'PM') partCode = cleanPartCode(val);
|
||||||
|
else if (key === 'QTY') {
|
||||||
|
const n = parseInt(val, 10);
|
||||||
|
if (!Number.isNaN(n)) quantity = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { partCode, quantity };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse fields separated by GS (0x1D) or RS (0x1E). Each field starts with a
|
||||||
|
// Data Identifier (DI) followed by its value. We care about part codes (30P
|
||||||
|
// beats 1P beats bare P) and quantity (Q).
|
||||||
|
function parseSeparatedFields(text: string): ParsedBarcode {
|
||||||
|
let pkgPart: string | null = null; // 30P — Digi-Key / container part
|
||||||
|
let mfrPart: string | null = null; // 1P — manufacturer part
|
||||||
|
let custPart: string | null = null; // P — customer part
|
||||||
|
let quantity: number | null = null;
|
||||||
|
|
||||||
|
for (const field of text.split(SEP_RE)) {
|
||||||
|
if (!field) continue;
|
||||||
|
if (field.startsWith('30P')) pkgPart = cleanPartCode(field.substring(3));
|
||||||
|
else if (field.startsWith('1P')) mfrPart = cleanPartCode(field.substring(2));
|
||||||
|
else if (field[0] === 'P') custPart = cleanPartCode(field.substring(1));
|
||||||
|
else if (field[0] === 'Q' && /^\d+$/.test(field.substring(1)))
|
||||||
|
quantity = parseInt(field.substring(1), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { partCode: pkgPart ?? mfrPart ?? custPart, quantity };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for MH10.8.2 barcodes whose GS/RS separators have been stripped.
|
||||||
|
// We can still pull out the part code by locating the next known DI, and the
|
||||||
|
// quantity by reading digits after `Q` until we hit a following DI.
|
||||||
|
function parseRunonMh10(body: string): ParsedBarcode {
|
||||||
|
let partCode: string | null = null;
|
||||||
|
let quantity: number | null = null;
|
||||||
|
|
||||||
|
if (body.length > 0 && body[0] === 'P') {
|
||||||
|
const afterP = body.substring(1);
|
||||||
|
let endIdx = afterP.length;
|
||||||
|
for (const marker of ['1P', '30P']) {
|
||||||
|
const idx = afterP.indexOf(marker);
|
||||||
|
if (idx !== -1 && idx < endIdx) endIdx = idx;
|
||||||
|
}
|
||||||
|
partCode = cleanPartCode(afterP.substring(0, endIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = 0;
|
||||||
|
while (from < body.length) {
|
||||||
|
const q = body.indexOf('Q', from);
|
||||||
|
if (q === -1) break;
|
||||||
|
let i = q + 1;
|
||||||
|
let digits = '';
|
||||||
|
while (i < body.length) {
|
||||||
|
if (DIS_AFTER_Q.some((di) => body.startsWith(di, i))) break;
|
||||||
|
const ch = body.charCodeAt(i);
|
||||||
|
if (ch < 48 || ch > 57) break;
|
||||||
|
digits += body[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (digits) {
|
||||||
|
quantity = parseInt(digits, 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
from = q + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { partCode, quantity };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseScan(raw: string): ParsedBarcode {
|
||||||
|
if (!raw) return { partCode: null, quantity: null };
|
||||||
|
|
||||||
|
if (raw.startsWith('{') && raw.includes('}')) return parseJsonBraces(raw);
|
||||||
|
|
||||||
|
if (MH10_ENVELOPE.test(raw)) {
|
||||||
|
const body = raw.replace(MH10_ENVELOPE, '');
|
||||||
|
if (SEP_RE_TEST.test(body)) return parseSeparatedFields(body);
|
||||||
|
return parseRunonMh10(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain (possibly GS/RS-separated) — e.g. a bare `30PABC\x1DQ5`.
|
||||||
|
return parseSeparatedFields(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_TO_MODE: Record<string, 'import' | 'update' | 'get' | 'locate'> = {
|
||||||
|
'MODE:ADD': 'import',
|
||||||
|
'MODE:IMPORT': 'import',
|
||||||
|
ADD_STOCK: 'import',
|
||||||
|
IMPORT: 'import',
|
||||||
|
'MODE:UPDATE': 'update',
|
||||||
|
UPDATE_STOCK: 'update',
|
||||||
|
UPDATE: 'update',
|
||||||
|
'MODE:CHECK': 'get',
|
||||||
|
'MODE:GET': 'get',
|
||||||
|
CHECK_STOCK: 'get',
|
||||||
|
CHECK: 'get',
|
||||||
|
'MODE:LOCATE': 'locate',
|
||||||
|
LOCATE_PART: 'locate',
|
||||||
|
LOCATE: 'locate',
|
||||||
|
FIND_PART: 'locate'
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCATION_COMMANDS = new Set([
|
||||||
|
'CHANGE_LOCATION',
|
||||||
|
'NEW_LOCATION',
|
||||||
|
'SET_LOCATION',
|
||||||
|
'LOCATION'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type BarcodeCommand =
|
||||||
|
| { type: 'mode'; mode: 'import' | 'update' | 'get' | 'locate' }
|
||||||
|
| { type: 'change_location' }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function interpretCommand(code: string): BarcodeCommand {
|
||||||
|
const upper = code.toUpperCase();
|
||||||
|
const mode = COMMAND_TO_MODE[upper];
|
||||||
|
if (mode) return { type: 'mode', mode };
|
||||||
|
if (LOCATION_COMMANDS.has(upper)) return { type: 'change_location' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLocationBarcode(input: string): number | null {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (trimmed.toUpperCase().startsWith('INV-SL')) {
|
||||||
|
const n = parseInt(trimmed.substring(6), 10);
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
const n = parseInt(trimmed, 10);
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { FailedScan, ScanMode, ScanSession } from './types';
|
||||||
|
|
||||||
|
export interface RetryFixes {
|
||||||
|
partCode?: string;
|
||||||
|
quantity?: number;
|
||||||
|
locationId?: number;
|
||||||
|
partId?: number;
|
||||||
|
retryImport?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanOutcome =
|
||||||
|
| { outcome: 'ok'; message: string; detail?: Record<string, unknown> }
|
||||||
|
| { outcome: 'failed'; failure: FailedScan }
|
||||||
|
| { outcome: 'pending_import'; partCode: string };
|
||||||
|
|
||||||
|
async function handle<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${res.status}: ${text || res.statusText}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
async config() {
|
||||||
|
return handle<{
|
||||||
|
host: string;
|
||||||
|
modes: Record<string, string>;
|
||||||
|
barcodeCommands: Record<string, string[]>;
|
||||||
|
importConcurrency: number;
|
||||||
|
}>(await fetch('/api/config'));
|
||||||
|
},
|
||||||
|
|
||||||
|
async locations() {
|
||||||
|
return handle<{ locations: Array<{ id: number; name: string; pathstring?: string }> }>(
|
||||||
|
await fetch('/api/locations')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async startSession(mode: ScanMode, locationId: number | null) {
|
||||||
|
return handle<{ session: ScanSession }>(
|
||||||
|
await fetch('/api/session/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode, locationId })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async endSession(sessionId: string) {
|
||||||
|
return handle<{ session: ScanSession }>(
|
||||||
|
await fetch('/api/session/end', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setSessionMode(sessionId: string, mode: ScanMode) {
|
||||||
|
return handle<{ session: ScanSession }>(
|
||||||
|
await fetch('/api/session/mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, mode })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setSessionLocation(sessionId: string, locationId: number | null) {
|
||||||
|
return handle<{ session: ScanSession }>(
|
||||||
|
await fetch('/api/session/location', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, locationId })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async scan(
|
||||||
|
sessionId: string,
|
||||||
|
rawBarcode: string,
|
||||||
|
overrides: Partial<{
|
||||||
|
partCode: string;
|
||||||
|
quantity: number;
|
||||||
|
locationId: number;
|
||||||
|
partId: number;
|
||||||
|
}> = {}
|
||||||
|
) {
|
||||||
|
return handle<{ outcome: ScanOutcome; session: ScanSession }>(
|
||||||
|
await fetch('/api/session/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, rawBarcode, ...overrides })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async retry(sessionId: string, failureId: string, fixes: RetryFixes) {
|
||||||
|
return handle<{ outcome: ScanOutcome; session: ScanSession }>(
|
||||||
|
await fetch('/api/session/retry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, failureId, fixes })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async dismissFailure(sessionId: string, failureId: string) {
|
||||||
|
return handle<{ session: ScanSession }>(
|
||||||
|
await fetch('/api/session/failures', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, failureId })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function failuresToCsv(failures: FailedScan[]): string {
|
||||||
|
const rows = [
|
||||||
|
['id', 'timestamp', 'reason', 'partCode', 'quantity', 'rawBarcode', 'message'],
|
||||||
|
...failures.map((f) => [
|
||||||
|
f.id,
|
||||||
|
f.timestamp,
|
||||||
|
f.reason,
|
||||||
|
f.parsedPartCode ?? '',
|
||||||
|
f.parsedQuantity ?? '',
|
||||||
|
f.rawBarcode,
|
||||||
|
f.message
|
||||||
|
])
|
||||||
|
];
|
||||||
|
return rows
|
||||||
|
.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\r\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { app } from '$lib/stores/app.svelte';
|
||||||
|
|
||||||
|
let container: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// re-run when log length changes
|
||||||
|
app.logs.length;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (container) container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function toneClass(t: string) {
|
||||||
|
switch (t) {
|
||||||
|
case 'success':
|
||||||
|
return 'text-emerald-700';
|
||||||
|
case 'warning':
|
||||||
|
return 'text-amber-700';
|
||||||
|
case 'error':
|
||||||
|
return 'text-rose-700';
|
||||||
|
default:
|
||||||
|
return 'text-sky-700';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-slate-800">Activity</h2>
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class="h-64 overflow-y-auto rounded-md border border-slate-200 bg-slate-50 p-3 font-mono text-sm"
|
||||||
|
>
|
||||||
|
{#each app.logs as log (log.id)}
|
||||||
|
<div class="py-0.5 {toneClass(log.type)}">
|
||||||
|
<span class="mr-2 text-slate-400">{log.time}</span>
|
||||||
|
<span>{log.message}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-slate-400">No activity yet.</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { app } from '$lib/stores/app.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium"
|
||||||
|
class:bg-emerald-100={app.connected}
|
||||||
|
class:text-emerald-700={app.connected}
|
||||||
|
class:bg-rose-100={!app.connected}
|
||||||
|
class:text-rose-700={!app.connected}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-2 w-2 rounded-full"
|
||||||
|
class:bg-emerald-500={app.connected}
|
||||||
|
class:bg-rose-500={!app.connected}
|
||||||
|
class:animate-pulse={app.connected}
|
||||||
|
></span>
|
||||||
|
{app.connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
{#if app.host}
|
||||||
|
<span class="text-slate-500">{app.host}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FailedScan } from '$lib/types';
|
||||||
|
import type { RetryFixes } from '$lib/client';
|
||||||
|
import { app } from '$lib/stores/app.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
failure,
|
||||||
|
onClose,
|
||||||
|
onSubmit
|
||||||
|
}: {
|
||||||
|
failure: FailedScan;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (fixes: RetryFixes) => void | Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Dialog is mounted under `{#key failure.id}` so these initial values
|
||||||
|
// always reflect the active failure.
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let partCode = $state(failure.parsedPartCode ?? '');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let quantity = $state<number | ''>(failure.parsedQuantity ?? '');
|
||||||
|
let locationId = $state<number | ''>(app.session?.locationId ?? '');
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let searchQuery = $state(failure.parsedPartCode ?? '');
|
||||||
|
let searching = $state(false);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let results = $state(failure.suggestions ?? []);
|
||||||
|
let selectedPartId = $state<number | null>(null);
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
const q = searchQuery.trim();
|
||||||
|
if (!q) return;
|
||||||
|
searching = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/part/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: q, limit: 10 })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
results = data.results ?? [];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
searching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFixes(fixes: RetryFixes) {
|
||||||
|
await onSubmit(fixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePickSuggestion(id: number) {
|
||||||
|
selectedPartId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApply() {
|
||||||
|
const fixes: RetryFixes = {};
|
||||||
|
if (partCode && partCode !== failure.parsedPartCode) fixes.partCode = partCode;
|
||||||
|
if (quantity !== '' && quantity !== failure.parsedQuantity) fixes.quantity = Number(quantity);
|
||||||
|
if (locationId !== '' && locationId !== app.session?.locationId)
|
||||||
|
fixes.locationId = Number(locationId);
|
||||||
|
if (selectedPartId != null) fixes.partId = selectedPartId;
|
||||||
|
submitFixes(fixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRetryImport() {
|
||||||
|
submitFixes({ retryImport: true, partCode: partCode || undefined });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-2xl rounded-lg border border-slate-200 bg-white p-6 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800">Fix failure</h3>
|
||||||
|
<p class="text-sm text-slate-600">{failure.message}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-md border border-slate-300 bg-white px-3 py-1 text-sm text-slate-700 hover:bg-slate-50"
|
||||||
|
>Close</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-slate-600">
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-400">Raw:</span>
|
||||||
|
<span class="ml-2 font-mono text-slate-800">{failure.rawBarcode}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-400">Reason:</span>
|
||||||
|
<span class="ml-2 text-slate-800">{failure.reason}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overrides form -->
|
||||||
|
<div class="mb-4 grid gap-3 md:grid-cols-3">
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="text-slate-600">Part code</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={partCode}
|
||||||
|
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="text-slate-600">Quantity</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={quantity}
|
||||||
|
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="text-slate-600">Location</span>
|
||||||
|
<select
|
||||||
|
bind:value={locationId}
|
||||||
|
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">(use session)</option>
|
||||||
|
{#each app.locations as loc (loc.id)}
|
||||||
|
<option value={loc.id}>{loc.id} — {loc.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search existing part -->
|
||||||
|
<div class="mb-4 rounded-md border border-slate-200 p-3">
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && runSearch()}
|
||||||
|
placeholder="Search existing parts…"
|
||||||
|
class="flex-1 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={runSearch}
|
||||||
|
disabled={searching}
|
||||||
|
class="rounded-md bg-brand-500 px-3 py-2 text-sm text-white shadow-sm hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>{searching ? 'Searching…' : 'Search'}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if results.length > 0}
|
||||||
|
<ul class="max-h-48 space-y-1 overflow-y-auto">
|
||||||
|
{#each results as r (r.pk)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handlePickSuggestion(r.pk)}
|
||||||
|
class="w-full rounded-md border px-3 py-2 text-left text-sm transition"
|
||||||
|
class:border-brand-500={selectedPartId === r.pk}
|
||||||
|
class:bg-brand-500={selectedPartId === r.pk}
|
||||||
|
class:text-white={selectedPartId === r.pk}
|
||||||
|
class:border-slate-200={selectedPartId !== r.pk}
|
||||||
|
class:bg-white={selectedPartId !== r.pk}
|
||||||
|
class:hover:bg-slate-50={selectedPartId !== r.pk}
|
||||||
|
>
|
||||||
|
<span class="font-mono">{r.IPN ?? '—'}</span>
|
||||||
|
<span class="ml-2 opacity-70">{r.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if searching}
|
||||||
|
<p class="text-sm text-slate-500">Searching…</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-slate-500">No suggestions yet. Enter a query above.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
{#if failure.reason === 'import_failed' || failure.reason === 'unknown_part'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleRetryImport}
|
||||||
|
class="rounded-md border border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-800 hover:bg-amber-100"
|
||||||
|
>Retry import</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleApply}
|
||||||
|
class="rounded-md bg-emerald-600 px-4 py-2 text-sm text-white shadow-sm hover:bg-emerald-700"
|
||||||
|
>Apply & retry</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { app } from '$lib/stores/app.svelte';
|
||||||
|
import type { FailedScan } from '$lib/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onFix,
|
||||||
|
onDismiss,
|
||||||
|
onExport
|
||||||
|
}: {
|
||||||
|
onFix: (failure: FailedScan) => void;
|
||||||
|
onDismiss: (failureId: string) => void;
|
||||||
|
onExport: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const failures = $derived(app.session?.failures ?? []);
|
||||||
|
|
||||||
|
const reasonLabel: Record<string, string> = {
|
||||||
|
unknown_part: 'Unknown part',
|
||||||
|
import_failed: 'Import failed',
|
||||||
|
parse_failed: 'Parse failed',
|
||||||
|
missing_quantity: 'Missing quantity',
|
||||||
|
invalid_location: 'No location',
|
||||||
|
api_error: 'API error'
|
||||||
|
};
|
||||||
|
|
||||||
|
const reasonTone: Record<string, string> = {
|
||||||
|
unknown_part: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
|
import_failed: 'bg-rose-50 text-rose-700 border-rose-200',
|
||||||
|
parse_failed: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||||
|
missing_quantity: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||||
|
invalid_location: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||||
|
api_error: 'bg-rose-50 text-rose-700 border-rose-200'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-800">
|
||||||
|
Failures
|
||||||
|
{#if failures.length > 0}
|
||||||
|
<span class="ml-2 rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700"
|
||||||
|
>{failures.length}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
{#if failures.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onExport}
|
||||||
|
class="rounded-md border border-slate-300 bg-white px-3 py-1 text-xs text-slate-700 hover:bg-slate-50"
|
||||||
|
>Export CSV</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if failures.length === 0}
|
||||||
|
<p class="text-sm text-slate-500">No failures.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each failures as f (f.id)}
|
||||||
|
<li class="rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-mono text-sm text-slate-900">
|
||||||
|
{f.parsedPartCode ?? '—'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-block rounded-full border px-2 py-0.5 text-xs {reasonTone[
|
||||||
|
f.reason
|
||||||
|
] ?? ''}">{reasonLabel[f.reason] ?? f.reason}</span
|
||||||
|
>
|
||||||
|
{#if f.parsedQuantity != null}
|
||||||
|
<span class="text-xs text-slate-500">qty {f.parsedQuantity}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-slate-700">{f.message}</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-400">
|
||||||
|
{new Date(f.timestamp).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onFix(f)}
|
||||||
|
class="rounded-md bg-brand-500 px-3 py-1 text-xs text-white shadow-sm hover:bg-brand-600"
|
||||||
|
>Fix</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onDismiss(f.id)}
|
||||||
|
class="rounded-md border border-slate-300 bg-white px-3 py-1 text-xs text-slate-600 hover:bg-slate-50"
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { app } from '$lib/stores/app.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
onChange: (locationId: number | null) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let scanInput = $state('');
|
||||||
|
|
||||||
|
const currentName = $derived.by(() => {
|
||||||
|
const id = app.session?.locationId;
|
||||||
|
if (id == null) return null;
|
||||||
|
const loc = app.locations.find((l) => l.id === id);
|
||||||
|
return loc ? `${loc.id} — ${loc.name}` : `Location ${id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleScan() {
|
||||||
|
const trimmed = scanInput.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
let id: number | null = null;
|
||||||
|
if (trimmed.toUpperCase().startsWith('INV-SL')) {
|
||||||
|
const n = parseInt(trimmed.substring(6), 10);
|
||||||
|
id = Number.isNaN(n) ? null : n;
|
||||||
|
} else {
|
||||||
|
const n = parseInt(trimmed, 10);
|
||||||
|
id = Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
if (id != null) onChange(id);
|
||||||
|
scanInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(e: Event) {
|
||||||
|
const value = (e.target as HTMLSelectElement).value;
|
||||||
|
onChange(value ? Number(value) : null);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-slate-800">Location</h2>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={scanInput}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && handleScan()}
|
||||||
|
placeholder="Scan location (INV-SL…) or enter ID"
|
||||||
|
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={app.session?.locationId ?? ''}
|
||||||
|
onchange={handleSelect}
|
||||||
|
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select a location…</option>
|
||||||
|
{#each app.locations as loc (loc.id)}
|
||||||
|
<option value={loc.id}>{loc.id} — {loc.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{#if currentName}
|
||||||
|
<p class="mt-3 text-sm text-emerald-700">Current: {currentName}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-3 text-sm text-slate-500">No location selected</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { app } from '$lib/stores/app.svelte';
|
||||||
|
import type { ScanMode } from '$lib/types';
|
||||||
|
|
||||||
|
let { onChange }: { onChange: (mode: ScanMode) => void } = $props();
|
||||||
|
|
||||||
|
const modes: ScanMode[] = ['import', 'update', 'get', 'locate'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-slate-800">Mode</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||||
|
{#each modes as mode (mode)}
|
||||||
|
{@const active = app.session?.mode === mode}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onChange(mode)}
|
||||||
|
class="rounded-md border px-3 py-2 text-sm font-medium transition"
|
||||||
|
class:border-brand-500={active}
|
||||||
|
class:bg-brand-500={active}
|
||||||
|
class:text-white={active}
|
||||||
|
class:shadow-sm={active}
|
||||||
|
class:border-slate-300={!active}
|
||||||
|
class:bg-white={!active}
|
||||||
|
class:text-slate-700={!active}
|
||||||
|
class:hover:bg-slate-50={!active}
|
||||||
|
>
|
||||||
|
{app.modes[mode] ?? mode}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { app } from '$lib/stores/app.svelte';
|
||||||
|
|
||||||
|
const items = $derived(app.session?.pendingImports ?? []);
|
||||||
|
|
||||||
|
function locationName(id: number | null): string {
|
||||||
|
if (id == null) return '—';
|
||||||
|
const loc = app.locations.find((l) => l.id === id);
|
||||||
|
return loc ? `${loc.id} — ${loc.name}` : `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elapsed(iso: string): string {
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
if (ms < 1000) return 'just now';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
return `${m}m ${s % 60}s ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick every second so "elapsed" stays fresh.
|
||||||
|
let now = $state(Date.now());
|
||||||
|
$effect(() => {
|
||||||
|
const id = setInterval(() => (now = Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
// Reference `now` so the template re-computes elapsed strings.
|
||||||
|
void now;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-800">
|
||||||
|
Pending imports
|
||||||
|
{#if items.length > 0}
|
||||||
|
<span class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700"
|
||||||
|
>{items.length}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
<span class="text-xs text-slate-500">
|
||||||
|
up to {app.importConcurrency} in parallel
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if items.length === 0}
|
||||||
|
<p class="text-sm text-slate-500">Nothing in the queue.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<li class="rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="relative flex h-2 w-2">
|
||||||
|
<span
|
||||||
|
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-70"
|
||||||
|
></span>
|
||||||
|
<span class="relative inline-flex h-2 w-2 rounded-full bg-amber-500"></span>
|
||||||
|
</span>
|
||||||
|
<span class="truncate font-mono text-sm font-medium text-slate-900"
|
||||||
|
>{item.partCode}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-500">
|
||||||
|
<span>Qty: <span class="text-slate-700">{item.quantity ?? '—'}</span></span>
|
||||||
|
<span>Loc: <span class="text-slate-700">{locationName(item.locationId)}</span></span>
|
||||||
|
<span
|
||||||
|
>Attempt: <span class="text-slate-700">{item.attempts}/3</span></span
|
||||||
|
>
|
||||||
|
<span title={item.queuedAt}>{elapsed(item.queuedAt)}</span>
|
||||||
|
</div>
|
||||||
|
{#if item.lastError}
|
||||||
|
<div class="mt-1 text-xs text-rose-600">
|
||||||
|
Last error: {item.lastError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
onScan,
|
||||||
|
disabled = false
|
||||||
|
}: {
|
||||||
|
onScan: (raw: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let value = $state('');
|
||||||
|
let inputEl: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return;
|
||||||
|
onScan(v);
|
||||||
|
value = '';
|
||||||
|
inputEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focus() {
|
||||||
|
inputEl?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-slate-800">Scan part</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && submit()}
|
||||||
|
{disabled}
|
||||||
|
placeholder="Scan barcode or type part code…"
|
||||||
|
class="flex-1 rounded-md border border-slate-300 bg-white px-4 py-3 text-lg shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={submit}
|
||||||
|
{disabled}
|
||||||
|
class="rounded-md bg-brand-500 px-5 py-3 font-medium text-white shadow-sm hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Process
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { app } from '$lib/stores/app.svelte';
|
||||||
|
import Stat from './Stat.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onStart,
|
||||||
|
onEnd
|
||||||
|
}: {
|
||||||
|
onStart: () => void;
|
||||||
|
onEnd: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const session = $derived(app.session);
|
||||||
|
const active = $derived(session != null && session.endedAt == null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-800">Scan session</h2>
|
||||||
|
{#if session}
|
||||||
|
<p class="text-xs text-slate-500">
|
||||||
|
{active ? 'Active' : 'Ended'} — started
|
||||||
|
{new Date(session.startedAt).toLocaleTimeString()}
|
||||||
|
{#if session.endedAt}
|
||||||
|
, ended {new Date(session.endedAt).toLocaleTimeString()}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-slate-500">No session yet</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if active}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onEnd}
|
||||||
|
class="rounded-md border border-rose-300 bg-white px-3 py-1.5 text-sm text-rose-700 hover:bg-rose-50"
|
||||||
|
>
|
||||||
|
End session
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onStart}
|
||||||
|
class="rounded-md bg-brand-500 px-3 py-1.5 text-sm text-white shadow-sm hover:bg-brand-600"
|
||||||
|
>
|
||||||
|
Start new session
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if session}
|
||||||
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<Stat label="Scanned" value={session.stats.scanned} tone="neutral" />
|
||||||
|
<Stat label="Succeeded" value={session.stats.succeeded} tone="ok" />
|
||||||
|
<Stat label="Pending" value={session.stats.pending} tone="warn" />
|
||||||
|
<Stat label="Failed" value={session.stats.failed} tone="err" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone = 'neutral'
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
tone?: 'neutral' | 'ok' | 'warn' | 'err';
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const toneClasses: Record<string, string> = {
|
||||||
|
neutral: 'border-slate-200 bg-slate-50 text-slate-800',
|
||||||
|
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
|
warn: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
|
err: 'border-rose-200 bg-rose-50 text-rose-700'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-md border p-3 {toneClasses[tone]}">
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-70">{label}</div>
|
||||||
|
<div class="text-2xl font-bold">{value}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { parseScan, interpretCommand, parseLocationBarcode } from '$lib/barcode';
|
||||||
|
export type { BarcodeCommand } from '$lib/barcode';
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
host: string;
|
||||||
|
token: string;
|
||||||
|
importConcurrency: number;
|
||||||
|
importTimeoutMs: number;
|
||||||
|
importBin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function int(value: string | undefined, fallback: number): number {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: Config | null = null;
|
||||||
|
|
||||||
|
function compute(): Config {
|
||||||
|
const host = env.INVENTREE_HOST;
|
||||||
|
const token = env.INVENTREE_TOKEN;
|
||||||
|
if (!host) throw new Error('Missing required env var: INVENTREE_HOST');
|
||||||
|
if (!token) throw new Error('Missing required env var: INVENTREE_TOKEN');
|
||||||
|
return {
|
||||||
|
host: host.replace(/\/$/, ''),
|
||||||
|
token,
|
||||||
|
importConcurrency: int(env.IMPORT_CONCURRENCY, 3),
|
||||||
|
importTimeoutMs: int(env.IMPORT_TIMEOUT_SEC, 60) * 1000,
|
||||||
|
importBin: env.INVENTREE_PART_IMPORT_BIN || 'inventree-part-import'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy proxy so importing this module never touches env; the check runs on
|
||||||
|
// first property access (inside a request handler, not during build analysis).
|
||||||
|
export const config = new Proxy({} as Config, {
|
||||||
|
get(_target, prop: keyof Config) {
|
||||||
|
cached ??= compute();
|
||||||
|
return cached[prop];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authHeaders = new Proxy({} as Record<string, string>, {
|
||||||
|
get(_target, prop: string) {
|
||||||
|
cached ??= compute();
|
||||||
|
if (prop === 'Authorization') return `Token ${cached.token}`;
|
||||||
|
if (prop === 'Content-Type') return 'application/json';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
ownKeys() {
|
||||||
|
return ['Authorization', 'Content-Type'];
|
||||||
|
},
|
||||||
|
getOwnPropertyDescriptor() {
|
||||||
|
return { enumerable: true, configurable: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { ServerEvent } from '$lib/types';
|
||||||
|
|
||||||
|
class Bus extends EventEmitter {
|
||||||
|
publish(event: ServerEvent): void {
|
||||||
|
this.emit('event', event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bus = new Bus();
|
||||||
|
bus.setMaxListeners(100);
|
||||||
|
|
||||||
|
export function subscribe(listener: (e: ServerEvent) => void): () => void {
|
||||||
|
bus.on('event', listener);
|
||||||
|
return () => bus.off('event', listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sseStream(): ReadableStream<Uint8Array> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
return new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const send = (event: ServerEvent) => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||||
|
} catch {
|
||||||
|
// stream closed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
send({ type: 'heartbeat', at: new Date().toISOString() });
|
||||||
|
unsubscribe = subscribe(send);
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
send({ type: 'heartbeat', at: new Date().toISOString() });
|
||||||
|
}, 20_000);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
unsubscribe?.();
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { config } from './env';
|
||||||
|
import { bus } from './events';
|
||||||
|
import { findPart } from './inventree';
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
partCode: string;
|
||||||
|
success: boolean;
|
||||||
|
partId: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resolver = (r: ImportResult) => void;
|
||||||
|
|
||||||
|
interface QueueItem {
|
||||||
|
partCode: string;
|
||||||
|
attempts: number;
|
||||||
|
sessionId: string | null;
|
||||||
|
resolvers: Resolver[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
class ImportQueue {
|
||||||
|
private queue: QueueItem[] = [];
|
||||||
|
private pending = new Map<string, QueueItem>(); // partCode → item (dedupes)
|
||||||
|
private activeWorkers = 0;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
enqueue(
|
||||||
|
partCode: string,
|
||||||
|
opts: { sessionId?: string | null } = {}
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
this.start();
|
||||||
|
const existing = this.pending.get(partCode);
|
||||||
|
if (existing) {
|
||||||
|
return new Promise((resolve) => existing.resolvers.push(resolve));
|
||||||
|
}
|
||||||
|
const item: QueueItem = {
|
||||||
|
partCode,
|
||||||
|
attempts: 0,
|
||||||
|
sessionId: opts.sessionId ?? null,
|
||||||
|
resolvers: []
|
||||||
|
};
|
||||||
|
this.pending.set(partCode, item);
|
||||||
|
this.queue.push(item);
|
||||||
|
bus.publish({ type: 'import_queued', partCode, sessionId: item.sessionId });
|
||||||
|
const promise = new Promise<ImportResult>((resolve) => item.resolvers.push(resolve));
|
||||||
|
this.pump();
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private start(): void {
|
||||||
|
this.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pump(): void {
|
||||||
|
while (this.activeWorkers < config.importConcurrency && this.queue.length > 0) {
|
||||||
|
const item = this.queue.shift();
|
||||||
|
if (!item) break;
|
||||||
|
this.activeWorkers++;
|
||||||
|
this.work(item).finally(() => {
|
||||||
|
this.activeWorkers--;
|
||||||
|
this.pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async work(item: QueueItem): Promise<void> {
|
||||||
|
item.attempts++;
|
||||||
|
const attemptResult = await this.runImport(item.partCode);
|
||||||
|
|
||||||
|
if (attemptResult.success && attemptResult.partId !== null) {
|
||||||
|
this.pending.delete(item.partCode);
|
||||||
|
bus.publish({
|
||||||
|
type: 'import_complete',
|
||||||
|
partCode: item.partCode,
|
||||||
|
partId: attemptResult.partId,
|
||||||
|
sessionId: item.sessionId
|
||||||
|
});
|
||||||
|
for (const r of item.resolvers) r(attemptResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.attempts < MAX_ATTEMPTS) {
|
||||||
|
bus.publish({
|
||||||
|
type: 'import_retry',
|
||||||
|
partCode: item.partCode,
|
||||||
|
attempts: item.attempts,
|
||||||
|
error: attemptResult.error ?? 'unknown error',
|
||||||
|
sessionId: item.sessionId
|
||||||
|
});
|
||||||
|
this.queue.push(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pending.delete(item.partCode);
|
||||||
|
bus.publish({
|
||||||
|
type: 'import_failed',
|
||||||
|
partCode: item.partCode,
|
||||||
|
error: attemptResult.error ?? 'unknown error',
|
||||||
|
sessionId: item.sessionId
|
||||||
|
});
|
||||||
|
for (const r of item.resolvers) r(attemptResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runImport(partCode: string): Promise<ImportResult> {
|
||||||
|
const spawnResult = await spawnImport(partCode);
|
||||||
|
|
||||||
|
// Always probe the server — `inventree-part-import` can create the part
|
||||||
|
// and then fail late on a secondary step (e.g. a duplicate parameter
|
||||||
|
// template). If the part is there, the import is usable regardless of
|
||||||
|
// the subprocess exit code.
|
||||||
|
let partId: number | null = null;
|
||||||
|
let lookupError: string | null = null;
|
||||||
|
try {
|
||||||
|
partId = await findPart(partCode);
|
||||||
|
} catch (e) {
|
||||||
|
lookupError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partId !== null) {
|
||||||
|
return { partCode, success: true, partId, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spawnResult.ok) {
|
||||||
|
return { partCode, success: false, partId: null, error: spawnResult.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
partCode,
|
||||||
|
success: false,
|
||||||
|
partId: null,
|
||||||
|
error: lookupError ?? 'Imported but part not found on server'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isStarted(): boolean {
|
||||||
|
return this.started;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats() {
|
||||||
|
return {
|
||||||
|
queued: this.queue.length,
|
||||||
|
inFlight: this.activeWorkers,
|
||||||
|
pending: this.pending.size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnImport(
|
||||||
|
partCode: string
|
||||||
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const child = spawn(config.importBin, [partCode], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (result: { ok: true } | { ok: false; error: string }) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
finish({ ok: false, error: `Import timeout (${config.importTimeoutMs / 1000}s)` });
|
||||||
|
}, config.importTimeoutMs);
|
||||||
|
|
||||||
|
child.stderr?.on('data', (d) => {
|
||||||
|
stderr += d.toString();
|
||||||
|
});
|
||||||
|
child.on('error', (err) => {
|
||||||
|
finish({ ok: false, error: `Failed to spawn ${config.importBin}: ${err.message}` });
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) finish({ ok: true });
|
||||||
|
else
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: `Import exited ${code}: ${stderr.trim().slice(-300) || 'no stderr'}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importQueue = new ImportQueue();
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { authHeaders, config } from './env';
|
||||||
|
import type { Location, PartInfo, PartParameter, StockItem } from '$lib/types';
|
||||||
|
|
||||||
|
async function req<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
body,
|
||||||
|
headers
|
||||||
|
}: { params?: Record<string, string | number>; body?: unknown; headers?: Record<string, string> } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = new URL(config.host + path);
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { ...authHeaders, ...(headers ?? {}) },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`InvenTree ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapResults<T>(data: unknown): T[] {
|
||||||
|
if (Array.isArray(data)) return data as T[];
|
||||||
|
if (data && typeof data === 'object' && 'results' in data) {
|
||||||
|
const r = (data as { results: unknown }).results;
|
||||||
|
return Array.isArray(r) ? (r as T[]) : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocations(): Promise<Location[]> {
|
||||||
|
const data = await req<unknown>('GET', '/api/stock/location/');
|
||||||
|
return unwrapResults<{ id?: number; pk?: number; name?: string; pathstring?: string }>(data).map(
|
||||||
|
(loc) => ({
|
||||||
|
id: (loc.id ?? loc.pk) as number,
|
||||||
|
name: loc.name ?? '',
|
||||||
|
pathstring: loc.pathstring
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocationDetails(id: number): Promise<Location> {
|
||||||
|
const data = await req<{ id?: number; pk?: number; name?: string; pathstring?: string }>(
|
||||||
|
'GET',
|
||||||
|
`/api/stock/location/${id}/`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: (data.id ?? data.pk) as number,
|
||||||
|
name: data.name ?? '',
|
||||||
|
pathstring: data.pathstring
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findPart(partCode: string): Promise<number | null> {
|
||||||
|
const data = await req<unknown>('GET', '/api/part/', { params: { search: partCode } });
|
||||||
|
const results = unwrapResults<{ pk?: number; id?: number }>(data);
|
||||||
|
if (!results.length) return null;
|
||||||
|
return (results[0].pk ?? results[0].id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchParts(
|
||||||
|
query: string,
|
||||||
|
limit = 10
|
||||||
|
): Promise<Array<{ pk: number; IPN: string | null; name: string }>> {
|
||||||
|
const data = await req<unknown>('GET', '/api/part/', {
|
||||||
|
params: { search: query, limit }
|
||||||
|
});
|
||||||
|
return unwrapResults<{ pk?: number; id?: number; IPN?: string | null; name?: string }>(data).map(
|
||||||
|
(p) => ({
|
||||||
|
pk: (p.pk ?? p.id) as number,
|
||||||
|
IPN: p.IPN ?? null,
|
||||||
|
name: p.name ?? ''
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPartInfo(partId: number): Promise<PartInfo> {
|
||||||
|
return req<PartInfo>('GET', `/api/part/${partId}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPartParameters(partId: number): Promise<PartParameter[]> {
|
||||||
|
const data = await req<unknown>('GET', '/api/part/parameter/', {
|
||||||
|
params: { part: partId }
|
||||||
|
});
|
||||||
|
return unwrapResults<PartParameter>(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPartStock(partId: number): Promise<StockItem[]> {
|
||||||
|
const data = await req<unknown>('GET', '/api/stock/', { params: { part: partId } });
|
||||||
|
return unwrapResults<StockItem>(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPartLocationsSummary(partId: number): Promise<{
|
||||||
|
locations: Array<{
|
||||||
|
locationId: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
quantity: number;
|
||||||
|
}>;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const items = await getPartStock(partId);
|
||||||
|
let total = 0;
|
||||||
|
const out = [] as Array<{
|
||||||
|
locationId: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
quantity: number;
|
||||||
|
}>;
|
||||||
|
for (const item of items) {
|
||||||
|
const qty = Number(item.quantity) || 0;
|
||||||
|
total += qty;
|
||||||
|
if (item.location == null) continue;
|
||||||
|
try {
|
||||||
|
const loc = await getLocationDetails(item.location);
|
||||||
|
out.push({
|
||||||
|
locationId: item.location,
|
||||||
|
name: loc.name,
|
||||||
|
path: loc.pathstring ?? '',
|
||||||
|
quantity: qty
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
out.push({
|
||||||
|
locationId: item.location,
|
||||||
|
name: `Location ${item.location}`,
|
||||||
|
path: '',
|
||||||
|
quantity: qty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { locations: out, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findStockItem(
|
||||||
|
partId: number,
|
||||||
|
locationId: number
|
||||||
|
): Promise<StockItem | null> {
|
||||||
|
const data = await req<unknown>('GET', '/api/stock/', {
|
||||||
|
params: { part: partId, location: locationId }
|
||||||
|
});
|
||||||
|
const results = unwrapResults<StockItem>(data);
|
||||||
|
return results[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStockLevel(partId: number, locationId: number): Promise<number> {
|
||||||
|
const item = await findStockItem(partId, locationId);
|
||||||
|
return item ? Number(item.quantity) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStockItem(
|
||||||
|
partId: number,
|
||||||
|
locationId: number,
|
||||||
|
quantity: number
|
||||||
|
): Promise<StockItem> {
|
||||||
|
const resp = await req<unknown>('POST', '/api/stock/', {
|
||||||
|
body: { part: partId, location: locationId, quantity }
|
||||||
|
});
|
||||||
|
const item = Array.isArray(resp) ? resp[0] : resp;
|
||||||
|
return item as StockItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchStockQuantity(stockItemId: number, quantity: number): Promise<void> {
|
||||||
|
await req('PATCH', `/api/stock/${stockItemId}/`, { body: { quantity } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImage(
|
||||||
|
url: string
|
||||||
|
): Promise<{ body: ArrayBuffer; contentType: string }> {
|
||||||
|
const absolute = url.startsWith('/') ? config.host + url : url;
|
||||||
|
const res = await fetch(absolute, {
|
||||||
|
headers: { Authorization: `Token ${config.token}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`);
|
||||||
|
return {
|
||||||
|
body: await res.arrayBuffer(),
|
||||||
|
contentType: res.headers.get('content-type') ?? 'image/jpeg'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ping(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(config.host + '/api/', { headers: authHeaders });
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
FailedScan,
|
||||||
|
FailureReason,
|
||||||
|
ScanMode,
|
||||||
|
ScanSession,
|
||||||
|
ServerEvent
|
||||||
|
} from '$lib/types';
|
||||||
|
import { bus, subscribe } from './events';
|
||||||
|
import { importQueue } from './importQueue';
|
||||||
|
import { parseScan } from './barcode';
|
||||||
|
import {
|
||||||
|
createStockItem,
|
||||||
|
findPart,
|
||||||
|
findStockItem,
|
||||||
|
getPartLocationsSummary,
|
||||||
|
getStockLevel,
|
||||||
|
patchStockQuantity,
|
||||||
|
searchParts
|
||||||
|
} from './inventree';
|
||||||
|
|
||||||
|
export interface ScanInput {
|
||||||
|
rawBarcode: string;
|
||||||
|
// overrides (used by retry flow)
|
||||||
|
partCode?: string | null;
|
||||||
|
quantity?: number | null;
|
||||||
|
locationId?: number | null;
|
||||||
|
partId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanOutcome =
|
||||||
|
| { outcome: 'ok'; message: string; detail?: Record<string, unknown> }
|
||||||
|
| { outcome: 'failed'; failure: FailedScan }
|
||||||
|
| { outcome: 'pending_import'; partCode: string };
|
||||||
|
|
||||||
|
interface InternalSession extends ScanSession {
|
||||||
|
// track pending imports for retry continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, InternalSession>();
|
||||||
|
|
||||||
|
function emitUpdate(session: InternalSession): void {
|
||||||
|
bus.publish({ type: 'session_update', session: clone(session) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone(session: InternalSession): ScanSession {
|
||||||
|
return JSON.parse(JSON.stringify(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession(input: {
|
||||||
|
mode: ScanMode;
|
||||||
|
locationId: number | null;
|
||||||
|
}): ScanSession {
|
||||||
|
const session: InternalSession = {
|
||||||
|
id: randomUUID(),
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
endedAt: null,
|
||||||
|
mode: input.mode,
|
||||||
|
locationId: input.locationId,
|
||||||
|
stats: { scanned: 0, succeeded: 0, failed: 0, pending: 0 },
|
||||||
|
failures: [],
|
||||||
|
pendingImports: []
|
||||||
|
};
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
emitUpdate(session);
|
||||||
|
return clone(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endSession(id: string): ScanSession | null {
|
||||||
|
const s = sessions.get(id);
|
||||||
|
if (!s) return null;
|
||||||
|
s.endedAt = new Date().toISOString();
|
||||||
|
emitUpdate(s);
|
||||||
|
return clone(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(id: string): ScanSession | null {
|
||||||
|
const s = sessions.get(id);
|
||||||
|
return s ? clone(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSessions(): ScanSession[] {
|
||||||
|
return [...sessions.values()].map(clone);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSessionMode(id: string, mode: ScanMode): ScanSession | null {
|
||||||
|
const s = sessions.get(id);
|
||||||
|
if (!s) return null;
|
||||||
|
s.mode = mode;
|
||||||
|
emitUpdate(s);
|
||||||
|
return clone(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSessionLocation(
|
||||||
|
id: string,
|
||||||
|
locationId: number | null
|
||||||
|
): ScanSession | null {
|
||||||
|
const s = sessions.get(id);
|
||||||
|
if (!s) return null;
|
||||||
|
s.locationId = locationId;
|
||||||
|
emitUpdate(s);
|
||||||
|
return clone(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailure(
|
||||||
|
session: InternalSession,
|
||||||
|
data: {
|
||||||
|
reason: FailureReason;
|
||||||
|
message: string;
|
||||||
|
rawBarcode: string;
|
||||||
|
parsedPartCode: string | null;
|
||||||
|
parsedQuantity: number | null;
|
||||||
|
suggestions?: FailedScan['suggestions'];
|
||||||
|
}
|
||||||
|
): FailedScan {
|
||||||
|
const failure: FailedScan = {
|
||||||
|
id: randomUUID(),
|
||||||
|
rawBarcode: data.rawBarcode,
|
||||||
|
parsedPartCode: data.parsedPartCode,
|
||||||
|
parsedQuantity: data.parsedQuantity,
|
||||||
|
reason: data.reason,
|
||||||
|
message: data.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
attempts: 1,
|
||||||
|
suggestions: data.suggestions
|
||||||
|
};
|
||||||
|
session.failures.push(failure);
|
||||||
|
session.stats.failed++;
|
||||||
|
emitUpdate(session);
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSucceeded(session: InternalSession): void {
|
||||||
|
session.stats.succeeded++;
|
||||||
|
emitUpdate(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissFailure(sessionId: string, failureId: string): ScanSession | null {
|
||||||
|
const s = sessions.get(sessionId);
|
||||||
|
if (!s) return null;
|
||||||
|
const before = s.failures.length;
|
||||||
|
s.failures = s.failures.filter((f) => f.id !== failureId);
|
||||||
|
if (s.failures.length < before) {
|
||||||
|
s.stats.failed = Math.max(0, s.stats.failed - 1);
|
||||||
|
emitUpdate(s);
|
||||||
|
}
|
||||||
|
return clone(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeMode(
|
||||||
|
mode: ScanMode,
|
||||||
|
partId: number,
|
||||||
|
locationId: number | null,
|
||||||
|
quantity: number | null
|
||||||
|
): Promise<{ message: string; detail?: Record<string, unknown> }> {
|
||||||
|
if (mode === 'locate') {
|
||||||
|
const summary = await getPartLocationsSummary(partId);
|
||||||
|
return {
|
||||||
|
message: `Found in ${summary.locations.length} location(s); total ${summary.total}`,
|
||||||
|
detail: summary as unknown as Record<string, unknown>
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'get') {
|
||||||
|
if (locationId === null) throw new Error('invalid_location');
|
||||||
|
const qty = await getStockLevel(partId, locationId);
|
||||||
|
return { message: `Current stock: ${qty}`, detail: { quantity: qty } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationId === null) throw new Error('invalid_location');
|
||||||
|
if (quantity === null || Number.isNaN(quantity)) throw new Error('missing_quantity');
|
||||||
|
|
||||||
|
if (mode === 'import') {
|
||||||
|
const existing = await findStockItem(partId, locationId);
|
||||||
|
if (existing) {
|
||||||
|
const current = Number(existing.quantity);
|
||||||
|
const next = current + quantity;
|
||||||
|
await patchStockQuantity(existing.pk, next);
|
||||||
|
return {
|
||||||
|
message: `Added ${quantity} (${current} → ${next})`,
|
||||||
|
detail: { previous: current, next, stockItemId: existing.pk }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const created = await createStockItem(partId, locationId, quantity);
|
||||||
|
return { message: `Created stock item with ${quantity}`, detail: { stockItemId: created.pk } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode === 'update'
|
||||||
|
const existing = await findStockItem(partId, locationId);
|
||||||
|
if (existing) {
|
||||||
|
const current = Number(existing.quantity);
|
||||||
|
await patchStockQuantity(existing.pk, quantity);
|
||||||
|
return {
|
||||||
|
message: `Updated stock ${current} → ${quantity}`,
|
||||||
|
detail: { previous: current, next: quantity, stockItemId: existing.pk }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const created = await createStockItem(partId, locationId, quantity);
|
||||||
|
return { message: `Created stock item with ${quantity}`, detail: { stockItemId: created.pk } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suggestParts(
|
||||||
|
partCode: string
|
||||||
|
): Promise<FailedScan['suggestions']> {
|
||||||
|
try {
|
||||||
|
const hits = await searchParts(partCode, 5);
|
||||||
|
return hits;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processScan(
|
||||||
|
sessionId: string,
|
||||||
|
input: ScanInput
|
||||||
|
): Promise<ScanOutcome> {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) throw new Error(`Unknown session ${sessionId}`);
|
||||||
|
if (session.endedAt) throw new Error('Session already ended');
|
||||||
|
|
||||||
|
session.stats.scanned++;
|
||||||
|
|
||||||
|
const parsed = parseScan(input.rawBarcode);
|
||||||
|
const partCode = input.partCode ?? parsed.partCode;
|
||||||
|
const quantity = input.quantity ?? parsed.quantity;
|
||||||
|
const locationId = input.locationId ?? session.locationId;
|
||||||
|
const mode = session.mode;
|
||||||
|
|
||||||
|
if (!partCode && input.partId == null) {
|
||||||
|
const failure = recordFailure(session, {
|
||||||
|
reason: 'parse_failed',
|
||||||
|
message: `Could not parse part code from barcode: ${truncate(input.rawBarcode)}`,
|
||||||
|
rawBarcode: input.rawBarcode,
|
||||||
|
parsedPartCode: null,
|
||||||
|
parsedQuantity: quantity
|
||||||
|
});
|
||||||
|
return { outcome: 'failed', failure };
|
||||||
|
}
|
||||||
|
|
||||||
|
let partId: number | null = input.partId ?? null;
|
||||||
|
if (partId == null && partCode) {
|
||||||
|
try {
|
||||||
|
partId = await findPart(partCode);
|
||||||
|
} catch (e) {
|
||||||
|
const failure = recordFailure(session, {
|
||||||
|
reason: 'api_error',
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
rawBarcode: input.rawBarcode,
|
||||||
|
parsedPartCode: partCode,
|
||||||
|
parsedQuantity: quantity
|
||||||
|
});
|
||||||
|
return { outcome: 'failed', failure };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partId == null) {
|
||||||
|
if (mode === 'import' && partCode) {
|
||||||
|
enqueueImportAndContinue(session, {
|
||||||
|
rawBarcode: input.rawBarcode,
|
||||||
|
partCode,
|
||||||
|
quantity,
|
||||||
|
locationId
|
||||||
|
});
|
||||||
|
return { outcome: 'pending_import', partCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = partCode ? await suggestParts(partCode) : [];
|
||||||
|
const failure = recordFailure(session, {
|
||||||
|
reason: 'unknown_part',
|
||||||
|
message: `Part "${partCode}" not found`,
|
||||||
|
rawBarcode: input.rawBarcode,
|
||||||
|
parsedPartCode: partCode,
|
||||||
|
parsedQuantity: quantity,
|
||||||
|
suggestions
|
||||||
|
});
|
||||||
|
return { outcome: 'failed', failure };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await executeMode(mode, partId, locationId, quantity);
|
||||||
|
markSucceeded(session);
|
||||||
|
return { outcome: 'ok', message: result.message, detail: result.detail };
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
const reason: FailureReason =
|
||||||
|
msg === 'missing_quantity'
|
||||||
|
? 'missing_quantity'
|
||||||
|
: msg === 'invalid_location'
|
||||||
|
? 'invalid_location'
|
||||||
|
: 'api_error';
|
||||||
|
const failure = recordFailure(session, {
|
||||||
|
reason,
|
||||||
|
message: reason === 'api_error' ? msg : humanReason(reason),
|
||||||
|
rawBarcode: input.rawBarcode,
|
||||||
|
parsedPartCode: partCode,
|
||||||
|
parsedQuantity: quantity
|
||||||
|
});
|
||||||
|
return { outcome: 'failed', failure };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPendingCount(session: InternalSession): void {
|
||||||
|
session.stats.pending = session.pendingImports.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePending(session: InternalSession, partCode: string): void {
|
||||||
|
session.pendingImports = session.pendingImports.filter((p) => p.partCode !== partCode);
|
||||||
|
syncPendingCount(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueImportAndContinue(
|
||||||
|
session: InternalSession,
|
||||||
|
ctx: {
|
||||||
|
rawBarcode: string;
|
||||||
|
partCode: string;
|
||||||
|
quantity: number | null;
|
||||||
|
locationId: number | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
// Deduplicate: if the same part is already being imported within this session,
|
||||||
|
// the worker-pool queue will merge our attempt with the in-flight one. Just
|
||||||
|
// refresh the visible entry's metadata and return without double-counting.
|
||||||
|
const existing = session.pendingImports.find((p) => p.partCode === ctx.partCode);
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity = ctx.quantity;
|
||||||
|
existing.locationId = ctx.locationId;
|
||||||
|
emitUpdate(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.pendingImports.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
partCode: ctx.partCode,
|
||||||
|
queuedAt: new Date().toISOString(),
|
||||||
|
attempts: 1,
|
||||||
|
quantity: ctx.quantity,
|
||||||
|
locationId: ctx.locationId,
|
||||||
|
mode: session.mode,
|
||||||
|
lastError: null
|
||||||
|
});
|
||||||
|
syncPendingCount(session);
|
||||||
|
emitUpdate(session);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const result = await importQueue.enqueue(ctx.partCode, { sessionId: session.id });
|
||||||
|
removePending(session, ctx.partCode);
|
||||||
|
|
||||||
|
if (result.success && result.partId !== null) {
|
||||||
|
try {
|
||||||
|
await executeMode(session.mode, result.partId, ctx.locationId, ctx.quantity);
|
||||||
|
markSucceeded(session);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
const reason: FailureReason =
|
||||||
|
msg === 'missing_quantity'
|
||||||
|
? 'missing_quantity'
|
||||||
|
: msg === 'invalid_location'
|
||||||
|
? 'invalid_location'
|
||||||
|
: 'api_error';
|
||||||
|
recordFailure(session, {
|
||||||
|
reason,
|
||||||
|
message: reason === 'api_error' ? msg : humanReason(reason),
|
||||||
|
rawBarcode: ctx.rawBarcode,
|
||||||
|
parsedPartCode: ctx.partCode,
|
||||||
|
parsedQuantity: ctx.quantity
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordFailure(session, {
|
||||||
|
reason: 'import_failed',
|
||||||
|
message: result.error ?? 'Import failed after retries',
|
||||||
|
rawBarcode: ctx.rawBarcode,
|
||||||
|
parsedPartCode: ctx.partCode,
|
||||||
|
parsedQuantity: ctx.quantity
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflect retry progress on the visible pending list as the worker pool
|
||||||
|
// surfaces `import_retry` events from the bus.
|
||||||
|
subscribe((event) => {
|
||||||
|
if (event.type !== 'import_retry') return;
|
||||||
|
if (!event.sessionId) return;
|
||||||
|
const session = sessions.get(event.sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const entry = session.pendingImports.find((p) => p.partCode === event.partCode);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.attempts = event.attempts + 1;
|
||||||
|
entry.lastError = event.error;
|
||||||
|
emitUpdate(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RetryFixes {
|
||||||
|
partCode?: string;
|
||||||
|
quantity?: number;
|
||||||
|
locationId?: number;
|
||||||
|
partId?: number; // user picked an existing part
|
||||||
|
retryImport?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryFailure(
|
||||||
|
sessionId: string,
|
||||||
|
failureId: string,
|
||||||
|
fixes: RetryFixes
|
||||||
|
): Promise<ScanOutcome> {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) throw new Error(`Unknown session ${sessionId}`);
|
||||||
|
const failure = session.failures.find((f) => f.id === failureId);
|
||||||
|
if (!failure) throw new Error(`Unknown failure ${failureId}`);
|
||||||
|
|
||||||
|
failure.attempts++;
|
||||||
|
|
||||||
|
// decrement failed stat; if the retry fails we'll re-record
|
||||||
|
session.stats.failed = Math.max(0, session.stats.failed - 1);
|
||||||
|
session.failures = session.failures.filter((f) => f.id !== failureId);
|
||||||
|
// don't emit yet — processScan will emit
|
||||||
|
session.stats.scanned--; // processScan will ++ again
|
||||||
|
|
||||||
|
const input: ScanInput = {
|
||||||
|
rawBarcode: failure.rawBarcode,
|
||||||
|
partCode: fixes.partCode ?? failure.parsedPartCode,
|
||||||
|
quantity: fixes.quantity ?? failure.parsedQuantity,
|
||||||
|
locationId: fixes.locationId ?? session.locationId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fixes.partId != null) input.partId = fixes.partId;
|
||||||
|
if (fixes.retryImport && input.partCode) {
|
||||||
|
session.stats.scanned++;
|
||||||
|
enqueueImportAndContinue(session, {
|
||||||
|
rawBarcode: failure.rawBarcode,
|
||||||
|
partCode: input.partCode,
|
||||||
|
quantity: input.quantity ?? null,
|
||||||
|
locationId: input.locationId ?? null
|
||||||
|
});
|
||||||
|
return { outcome: 'pending_import', partCode: input.partCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
return processScan(sessionId, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n = 60): string {
|
||||||
|
return s.length <= n ? s : s.slice(0, n) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanReason(r: FailureReason): string {
|
||||||
|
switch (r) {
|
||||||
|
case 'missing_quantity':
|
||||||
|
return 'No quantity found in scan';
|
||||||
|
case 'invalid_location':
|
||||||
|
return 'No location selected for this operation';
|
||||||
|
default:
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ServerEvent };
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type {
|
||||||
|
Location,
|
||||||
|
ScanMode,
|
||||||
|
ScanSession,
|
||||||
|
ServerEvent
|
||||||
|
} from '$lib/types';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number;
|
||||||
|
time: string;
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppState {
|
||||||
|
host = $state('');
|
||||||
|
modes: Record<string, string> = $state({});
|
||||||
|
importConcurrency = $state(3);
|
||||||
|
|
||||||
|
locations = $state<Location[]>([]);
|
||||||
|
session = $state<ScanSession | null>(null);
|
||||||
|
connected = $state(false);
|
||||||
|
lastScanAt = $state<string | null>(null);
|
||||||
|
|
||||||
|
logs = $state<LogEntry[]>([]);
|
||||||
|
private nextLogId = 1;
|
||||||
|
private maxLogs = 200;
|
||||||
|
|
||||||
|
log(type: LogEntry['type'], message: string): void {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
id: this.nextLogId++,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
type,
|
||||||
|
message
|
||||||
|
};
|
||||||
|
this.logs = [...this.logs, entry].slice(-this.maxLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(session: ScanSession | null): void {
|
||||||
|
this.session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleServerEvent(event: ServerEvent): void {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'heartbeat':
|
||||||
|
this.connected = true;
|
||||||
|
break;
|
||||||
|
case 'session_update':
|
||||||
|
if (this.session && event.session.id === this.session.id) {
|
||||||
|
this.session = event.session;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'import_queued':
|
||||||
|
this.log('info', `Queued for import: ${event.partCode}`);
|
||||||
|
break;
|
||||||
|
case 'import_complete':
|
||||||
|
this.log('success', `Imported: ${event.partCode}`);
|
||||||
|
break;
|
||||||
|
case 'import_retry':
|
||||||
|
this.log(
|
||||||
|
'warning',
|
||||||
|
`Import retry ${event.attempts}/3: ${event.partCode} — ${event.error}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'import_failed':
|
||||||
|
this.log('error', `Import failed: ${event.partCode} — ${event.error}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const app = new AppState();
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
export type ScanMode = 'import' | 'update' | 'get' | 'locate';
|
||||||
|
|
||||||
|
export const MODE_NAMES: Record<ScanMode, string> = {
|
||||||
|
import: 'Add Stock',
|
||||||
|
update: 'Update Stock',
|
||||||
|
get: 'Check Stock',
|
||||||
|
locate: 'Locate Part'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BARCODE_COMMANDS = {
|
||||||
|
import: ['MODE:ADD', 'MODE:IMPORT', 'ADD_STOCK', 'IMPORT'],
|
||||||
|
update: ['MODE:UPDATE', 'UPDATE_STOCK', 'UPDATE'],
|
||||||
|
get: ['MODE:CHECK', 'MODE:GET', 'CHECK_STOCK', 'CHECK'],
|
||||||
|
locate: ['MODE:LOCATE', 'LOCATE_PART', 'LOCATE', 'FIND_PART'],
|
||||||
|
debug_on: ['DEBUG:ON', 'DEBUG_ON'],
|
||||||
|
debug_off: ['DEBUG:OFF', 'DEBUG_OFF'],
|
||||||
|
change_location: ['CHANGE_LOCATION', 'NEW_LOCATION', 'SET_LOCATION', 'LOCATION']
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
pathstring?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartInfo {
|
||||||
|
pk: number;
|
||||||
|
IPN: string | null;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
image: string | null;
|
||||||
|
thumbnail: string | null;
|
||||||
|
category: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartParameter {
|
||||||
|
pk: number;
|
||||||
|
data: string | null;
|
||||||
|
template_detail?: { name: string; units?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockItem {
|
||||||
|
pk: number;
|
||||||
|
part: number;
|
||||||
|
location: number | null;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartLocation {
|
||||||
|
location_id: number;
|
||||||
|
location_name: string;
|
||||||
|
location_path: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FailureReason =
|
||||||
|
| 'unknown_part'
|
||||||
|
| 'import_failed'
|
||||||
|
| 'parse_failed'
|
||||||
|
| 'missing_quantity'
|
||||||
|
| 'invalid_location'
|
||||||
|
| 'api_error';
|
||||||
|
|
||||||
|
export interface FailedScan {
|
||||||
|
id: string;
|
||||||
|
rawBarcode: string;
|
||||||
|
parsedPartCode: string | null;
|
||||||
|
parsedQuantity: number | null;
|
||||||
|
reason: FailureReason;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
attempts: number;
|
||||||
|
suggestions?: Array<{ pk: number; IPN: string | null; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingImport {
|
||||||
|
id: string;
|
||||||
|
partCode: string;
|
||||||
|
queuedAt: string;
|
||||||
|
attempts: number;
|
||||||
|
quantity: number | null;
|
||||||
|
locationId: number | null;
|
||||||
|
mode: ScanMode;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanSession {
|
||||||
|
id: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
mode: ScanMode;
|
||||||
|
locationId: number | null;
|
||||||
|
stats: {
|
||||||
|
scanned: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
failures: FailedScan[];
|
||||||
|
pendingImports: PendingImport[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedBarcode {
|
||||||
|
partCode: string | null;
|
||||||
|
quantity: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerEvent =
|
||||||
|
| { type: 'heartbeat'; at: string }
|
||||||
|
| { type: 'session_update'; session: ScanSession }
|
||||||
|
| { type: 'import_queued'; partCode: string; sessionId: string | null }
|
||||||
|
| {
|
||||||
|
type: 'import_complete';
|
||||||
|
partCode: string;
|
||||||
|
partId: number;
|
||||||
|
sessionId: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'import_retry';
|
||||||
|
partCode: string;
|
||||||
|
attempts: number;
|
||||||
|
error: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'import_failed';
|
||||||
|
partCode: string;
|
||||||
|
error: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { app } from '$lib/stores/app.svelte';
|
||||||
|
import { api, failuresToCsv, type RetryFixes } from '$lib/client';
|
||||||
|
import ConnectionStatus from '$lib/components/ConnectionStatus.svelte';
|
||||||
|
import LocationPicker from '$lib/components/LocationPicker.svelte';
|
||||||
|
import ModeSelector from '$lib/components/ModeSelector.svelte';
|
||||||
|
import ScanInput from '$lib/components/ScanInput.svelte';
|
||||||
|
import SessionPanel from '$lib/components/SessionPanel.svelte';
|
||||||
|
import PendingList from '$lib/components/PendingList.svelte';
|
||||||
|
import FailureList from '$lib/components/FailureList.svelte';
|
||||||
|
import FailureFixDialog from '$lib/components/FailureFixDialog.svelte';
|
||||||
|
import ActivityLog from '$lib/components/ActivityLog.svelte';
|
||||||
|
import { interpretCommand, parseLocationBarcode } from '$lib/barcode';
|
||||||
|
import type { FailedScan, ScanMode } from '$lib/types';
|
||||||
|
|
||||||
|
let fixing = $state<FailedScan | null>(null);
|
||||||
|
let es: EventSource | null = null;
|
||||||
|
let awaitingLocationChange = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
bootstrap();
|
||||||
|
return () => {
|
||||||
|
es?.close();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
try {
|
||||||
|
const cfg = await api.config();
|
||||||
|
app.host = cfg.host;
|
||||||
|
app.modes = cfg.modes;
|
||||||
|
app.importConcurrency = cfg.importConcurrency;
|
||||||
|
|
||||||
|
const locs = await api.locations();
|
||||||
|
app.locations = locs.locations;
|
||||||
|
|
||||||
|
await startNewSession();
|
||||||
|
connectSSE();
|
||||||
|
app.log('success', 'Ready to scan');
|
||||||
|
} catch (e) {
|
||||||
|
app.log('error', `Init failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
es = new EventSource('/api/events');
|
||||||
|
es.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(ev.data);
|
||||||
|
app.handleServerEvent(event);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
app.connected = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startNewSession() {
|
||||||
|
const mode: ScanMode = app.session?.mode ?? 'import';
|
||||||
|
const locationId = app.session?.locationId ?? null;
|
||||||
|
const { session } = await api.startSession(mode, locationId);
|
||||||
|
app.setSession(session);
|
||||||
|
app.log('info', `New session started (${session.id.slice(0, 8)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endSession() {
|
||||||
|
if (!app.session) return;
|
||||||
|
const { session } = await api.endSession(app.session.id);
|
||||||
|
app.setSession(session);
|
||||||
|
app.log('info', 'Session ended');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModeChange(mode: ScanMode) {
|
||||||
|
if (!app.session) return;
|
||||||
|
const { session } = await api.setSessionMode(app.session.id, mode);
|
||||||
|
app.setSession(session);
|
||||||
|
app.log('info', `Mode: ${app.modes[mode] ?? mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLocationChange(locationId: number | null) {
|
||||||
|
if (!app.session) return;
|
||||||
|
const { session } = await api.setSessionLocation(app.session.id, locationId);
|
||||||
|
app.setSession(session);
|
||||||
|
if (locationId != null) {
|
||||||
|
const loc = app.locations.find((l) => l.id === locationId);
|
||||||
|
app.log('info', `Location: ${loc ? `${loc.id} — ${loc.name}` : `#${locationId}`}`);
|
||||||
|
}
|
||||||
|
awaitingLocationChange = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScan(raw: string) {
|
||||||
|
if (!app.session || app.session.endedAt) await startNewSession();
|
||||||
|
if (!app.session) return;
|
||||||
|
|
||||||
|
if (awaitingLocationChange) {
|
||||||
|
const id = parseLocationBarcode(raw);
|
||||||
|
if (id != null) await handleLocationChange(id);
|
||||||
|
else app.log('warning', `Could not parse location from: ${raw}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = interpretCommand(raw);
|
||||||
|
if (cmd?.type === 'mode') {
|
||||||
|
await handleModeChange(cmd.mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cmd?.type === 'change_location') {
|
||||||
|
awaitingLocationChange = true;
|
||||||
|
app.log('info', 'Scan new location…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { outcome, session } = await api.scan(app.session.id, raw);
|
||||||
|
if (session) app.setSession(session);
|
||||||
|
app.lastScanAt = new Date().toISOString();
|
||||||
|
|
||||||
|
if (outcome.outcome === 'ok') app.log('success', outcome.message);
|
||||||
|
else if (outcome.outcome === 'pending_import')
|
||||||
|
app.log('info', `Queued import: ${outcome.partCode}`);
|
||||||
|
else app.log('error', `${outcome.failure.reason}: ${outcome.failure.message}`);
|
||||||
|
} catch (e) {
|
||||||
|
app.log('error', `Scan error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFix(f: FailedScan) {
|
||||||
|
fixing = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFix(fixes: RetryFixes) {
|
||||||
|
if (!app.session || !fixing) return;
|
||||||
|
const current = fixing;
|
||||||
|
fixing = null;
|
||||||
|
try {
|
||||||
|
const { outcome, session } = await api.retry(app.session.id, current.id, fixes);
|
||||||
|
if (session) app.setSession(session);
|
||||||
|
if (outcome.outcome === 'ok') app.log('success', `Fixed: ${outcome.message}`);
|
||||||
|
else if (outcome.outcome === 'pending_import')
|
||||||
|
app.log('info', `Re-queued import: ${outcome.partCode}`);
|
||||||
|
else app.log('error', `Retry failed: ${outcome.failure.message}`);
|
||||||
|
} catch (e) {
|
||||||
|
app.log('error', `Retry error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDismiss(failureId: string) {
|
||||||
|
if (!app.session) return;
|
||||||
|
const { session } = await api.dismissFailure(app.session.id, failureId);
|
||||||
|
app.setSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
if (!app.session) return;
|
||||||
|
const csv = failuresToCsv(app.session.failures);
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `failures-${app.session.id.slice(0, 8)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<header class="border-b border-slate-200 bg-white">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
|
||||||
|
<h1 class="text-xl font-bold text-slate-900">InvenTree Stock Tool</h1>
|
||||||
|
<ConnectionStatus />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-7xl space-y-4 px-4 py-6">
|
||||||
|
<SessionPanel onStart={startNewSession} onEnd={endSession} />
|
||||||
|
|
||||||
|
{#if awaitingLocationChange}
|
||||||
|
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
Waiting for next scan to be interpreted as a location…
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-2 underline"
|
||||||
|
onclick={() => (awaitingLocationChange = false)}>cancel</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<!-- Left column: scanner + recent activity -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<LocationPicker onChange={handleLocationChange} />
|
||||||
|
<ModeSelector onChange={handleModeChange} />
|
||||||
|
<ScanInput onScan={handleScan} disabled={!app.session || !!app.session.endedAt} />
|
||||||
|
<ActivityLog />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: queue -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<PendingList />
|
||||||
|
<FailureList onFix={handleFix} onDismiss={handleDismiss} onExport={handleExport} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if fixing}
|
||||||
|
{#key fixing.id}
|
||||||
|
<FailureFixDialog
|
||||||
|
failure={fixing}
|
||||||
|
onClose={() => (fixing = null)}
|
||||||
|
onSubmit={submitFix}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { config } from '$lib/server/env';
|
||||||
|
import { BARCODE_COMMANDS, MODE_NAMES } from '$lib/types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = () => {
|
||||||
|
return json({
|
||||||
|
host: config.host,
|
||||||
|
modes: MODE_NAMES,
|
||||||
|
barcodeCommands: BARCODE_COMMANDS,
|
||||||
|
importConcurrency: config.importConcurrency
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { sseStream } from '$lib/server/events';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = () => {
|
||||||
|
return new Response(sseStream(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getLocations } from '$lib/server/inventree';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
try {
|
||||||
|
const locations = await getLocations();
|
||||||
|
return json({ locations });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
error(502, msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { searchParts } from '$lib/server/inventree';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const { query, limit } = (await request.json().catch(() => ({}))) as {
|
||||||
|
query?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
if (!query) error(400, 'query required');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchParts(query, limit ?? 10);
|
||||||
|
return json({ results });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
error(502, msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { fetchImage } from '$lib/server/inventree';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const target = url.searchParams.get('url');
|
||||||
|
if (!target) error(400, 'url parameter required');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { body, contentType } = await fetchImage(target);
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'private, max-age=300'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
error(502, msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { endSession } from '$lib/server/sessions';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const { sessionId } = (await request.json().catch(() => ({}))) as { sessionId?: string };
|
||||||
|
if (!sessionId) error(400, 'sessionId required');
|
||||||
|
const session = endSession(sessionId);
|
||||||
|
if (!session) error(404, 'session not found');
|
||||||
|
return json({ session });
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { dismissFailure, getSession } from '$lib/server/sessions';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = ({ url }) => {
|
||||||
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
|
if (!sessionId) error(400, 'sessionId required');
|
||||||
|
const session = getSession(sessionId);
|
||||||
|
if (!session) error(404, 'session not found');
|
||||||
|
return json({ failures: session.failures });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ request }) => {
|
||||||
|
const { sessionId, failureId } = (await request.json().catch(() => ({}))) as {
|
||||||
|
sessionId?: string;
|
||||||
|
failureId?: string;
|
||||||
|
};
|
||||||
|
if (!sessionId || !failureId) error(400, 'sessionId and failureId required');
|
||||||
|
const session = dismissFailure(sessionId, failureId);
|
||||||
|
if (!session) error(404, 'session not found');
|
||||||
|
return json({ session });
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { updateSessionLocation } from '$lib/server/sessions';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const { sessionId, locationId } = (await request.json().catch(() => ({}))) as {
|
||||||
|
sessionId?: string;
|
||||||
|
locationId?: number | null;
|
||||||
|
};
|
||||||
|
if (!sessionId) error(400, 'sessionId required');
|
||||||
|
const session = updateSessionLocation(sessionId, locationId ?? null);
|
||||||
|
if (!session) error(404, 'session not found');
|
||||||
|
return json({ session });
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { updateSessionMode } from '$lib/server/sessions';
|
||||||
|
import type { ScanMode } from '$lib/types';
|
||||||
|
|
||||||
|
const VALID: ScanMode[] = ['import', 'update', 'get', 'locate'];
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const { sessionId, mode } = (await request.json().catch(() => ({}))) as {
|
||||||
|
sessionId?: string;
|
||||||
|
mode?: ScanMode;
|
||||||
|
};
|
||||||
|
if (!sessionId) error(400, 'sessionId required');
|
||||||
|
if (!mode || !VALID.includes(mode)) error(400, 'valid mode required');
|
||||||
|
const session = updateSessionMode(sessionId, mode);
|
||||||
|
if (!session) error(404, 'session not found');
|
||||||
|
return json({ session });
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { retryFailure, getSession, type RetryFixes } from '$lib/server/sessions';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
sessionId?: string;
|
||||||
|
failureId?: string;
|
||||||
|
fixes?: RetryFixes;
|
||||||
|
};
|
||||||
|
if (!body.sessionId) error(400, 'sessionId required');
|
||||||
|
if (!body.failureId) error(400, 'failureId required');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outcome = await retryFailure(body.sessionId, body.failureId, body.fixes ?? {});
|
||||||
|
return json({ outcome, session: getSession(body.sessionId) });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
error(400, msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { processScan, getSession } from '$lib/server/sessions';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
sessionId?: string;
|
||||||
|
rawBarcode?: string;
|
||||||
|
partCode?: string | null;
|
||||||
|
quantity?: number | null;
|
||||||
|
locationId?: number | null;
|
||||||
|
partId?: number | null;
|
||||||
|
};
|
||||||
|
if (!body.sessionId) error(400, 'sessionId required');
|
||||||
|
if (body.rawBarcode == null) error(400, 'rawBarcode required');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const outcome = await processScan(body.sessionId, {
|
||||||
|
rawBarcode: body.rawBarcode,
|
||||||
|
partCode: body.partCode ?? null,
|
||||||
|
quantity: body.quantity ?? null,
|
||||||
|
locationId: body.locationId ?? null,
|
||||||
|
partId: body.partId ?? null
|
||||||
|
});
|
||||||
|
return json({ outcome, session: getSession(body.sessionId) });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
error(400, msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { createSession } from '$lib/server/sessions';
|
||||||
|
import type { ScanMode } from '$lib/types';
|
||||||
|
|
||||||
|
const VALID_MODES: ScanMode[] = ['import', 'update', 'get', 'locate'];
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
mode?: ScanMode;
|
||||||
|
locationId?: number | null;
|
||||||
|
};
|
||||||
|
const mode = body.mode ?? 'import';
|
||||||
|
if (!VALID_MODES.includes(mode)) error(400, `invalid mode: ${mode}`);
|
||||||
|
|
||||||
|
const session = createSession({
|
||||||
|
mode,
|
||||||
|
locationId: body.locationId ?? null
|
||||||
|
});
|
||||||
|
return json({ session });
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user