Refactor routing and component structure by removing transactions-related routes and components, consolidating login functionality across multiple nodes. Update type definitions and server load functions to reflect these changes, enhancing code maintainability and streamlining user navigation. Adjust UI components to focus on receipts, improving user experience.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m52s

This commit is contained in:
eewing
2026-02-26 10:13:28 -06:00
parent f8b8ecd88c
commit 39bea5e7a9
40 changed files with 40 additions and 2126 deletions
+3 -19
View File
@@ -8,34 +8,18 @@ export const nodes = [
() => import('./nodes/4'),
() => import('./nodes/5'),
() => import('./nodes/6'),
() => import('./nodes/7'),
() => import('./nodes/8'),
() => import('./nodes/9'),
() => import('./nodes/10'),
() => import('./nodes/11'),
() => import('./nodes/12'),
() => import('./nodes/13'),
() => import('./nodes/14'),
() => import('./nodes/15')
() => import('./nodes/7')
];
export const server_loads = [0];
export const dictionary = {
"/": [~2],
"/accounts": [~10],
"/accounts/new": [11],
"/accounts/[id]": [12],
"/items": [~13],
"/items/[id]": [~14],
"/login": [~15],
"/login": [~7],
"/logout": [~6],
"/receipts": [~3],
"/receipts/new": [4],
"/receipts/[id]": [~5],
"/transactions": [~7],
"/transactions/new": [8],
"/transactions/[id]": [9]
"/receipts/[id]": [~5]
};
export const hooks = {
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/accounts/+page.svelte";
export { default as component } from "../../../../src/routes/login/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/accounts/new/+page.svelte";
export { default as component } from "../../../../src/routes/login/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/accounts/[id]/+page.svelte";
export { default as component } from "../../../../src/routes/login/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/items/+page.svelte";
export { default as component } from "../../../../src/routes/login/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/items/[id]/+page.svelte";
export { default as component } from "../../../../src/routes/login/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/transactions/+page.svelte";
export { default as component } from "../../../../src/routes/login/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/transactions/new/+page.svelte";
export { default as component } from "../../../../src/routes/login/+page.svelte";
+1 -1
View File
@@ -1 +1 @@
export { default as component } from "../../../../src/routes/transactions/[id]/+page.svelte";
export { default as component } from "../../../../src/routes/login/+page.svelte";
+1 -1
View File
@@ -56,7 +56,7 @@ declare module "$app/types" {
"/transactions/new": Record<string, never>;
"/transactions/[id]": { id: string }
};
Pathname(): "/" | "/accounts" | "/accounts/new" | `/accounts/${string}` & {} | `/api/files/${string}` & {} | "/items" | `/items/${string}` & {} | "/login" | "/logout" | "/receipts" | "/receipts/new" | `/receipts/${string}` & {} | "/transactions" | "/transactions/new" | `/transactions/${string}` & {};
Pathname(): "/" | `/api/files/${string}` & {} | "/login" | "/logout" | "/receipts" | "/receipts/new" | `/receipts/${string}` & {};
ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
Asset(): "/icon.svg" | "/manifest.webmanifest" | string & {};
}
-36
View File
@@ -6,32 +6,9 @@
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
],
"/accounts": [
"src/routes/accounts/+page.server.ts",
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
],
"/accounts/new": [
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
],
"/accounts/[id]": [
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
],
"/api/files/[...path]": [
"src/routes/api/files/[...path]/+server.ts"
],
"/items": [
"src/routes/items/+page.server.ts",
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
],
"/items/[id]": [
"src/routes/items/[id]/+page.server.ts",
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
],
"/login": [
"src/routes/login/+page.server.ts",
"src/routes/+layout.ts",
@@ -55,18 +32,5 @@
"src/routes/receipts/[id]/+page.server.ts",
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
],
"/transactions": [
"src/routes/transactions/+page.server.ts",
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
],
"/transactions/new": [
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
],
"/transactions/[id]": [
"src/routes/+layout.ts",
"src/routes/+layout.server.ts"
]
}
+1 -1
View File
@@ -13,7 +13,7 @@ type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = EnsureDefined<LayoutServerData>;
type PageParentData = EnsureDefined<LayoutData>;
type LayoutRouteId = RouteId | "/" | "/receipts" | "/receipts/new" | "/receipts/[id]" | "/logout" | "/transactions" | "/transactions/new" | "/transactions/[id]" | "/accounts" | "/accounts/new" | "/accounts/[id]" | "/items" | "/items/[id]" | "/login" | null
type LayoutRouteId = RouteId | "/" | "/receipts" | "/receipts/new" | "/receipts/[id]" | "/logout" | "/login" | null
type LayoutParams = RouteParams & { id?: string }
type LayoutServerParentData = EnsureDefined<{}>;
type LayoutParentData = EnsureDefined<{}>;
+3 -16
View File
@@ -11,21 +11,8 @@ type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Pa
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = EnsureDefined<import('../$types.js').LayoutServerData>;
type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
type ExcludeActionFailure<T> = T extends Kit.ActionFailure<any> ? never : T extends void ? never : T;
type ActionsSuccess<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: ExcludeActionFailure<Awaited<ReturnType<T[Key]>>>; }[keyof T];
type ExtractActionFailure<T> = T extends Kit.ActionFailure<infer X> ? X extends void ? never : X : never;
type ActionsFailure<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: Exclude<ExtractActionFailure<Awaited<ReturnType<T[Key]>>>, void>; }[keyof T];
type ActionsExport = typeof import('./proxy+page.server.js').actions
export type SubmitFunction = Kit.SubmitFunction<Expand<ActionsSuccess<ActionsExport>>, Expand<ActionsFailure<ActionsExport>>>
export type ActionData = Expand<Kit.AwaitedActions<ActionsExport>> | null;
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('./proxy+page.server.js').load>>>>>>;
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
export type PageServerData = null;
export type PageData = Expand<PageParentData>;
export type PageProps = { params: RouteParams; data: PageData }
+3 -16
View File
@@ -11,22 +11,9 @@ type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Pa
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = EnsureDefined<import('../../$types.js').LayoutServerData>;
type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>;
export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
type ExcludeActionFailure<T> = T extends Kit.ActionFailure<any> ? never : T extends void ? never : T;
type ActionsSuccess<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: ExcludeActionFailure<Awaited<ReturnType<T[Key]>>>; }[keyof T];
type ExtractActionFailure<T> = T extends Kit.ActionFailure<infer X> ? X extends void ? never : X : never;
type ActionsFailure<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: Exclude<ExtractActionFailure<Awaited<ReturnType<T[Key]>>>, void>; }[keyof T];
type ActionsExport = typeof import('./proxy+page.server.js').actions
export type SubmitFunction = Kit.SubmitFunction<Expand<ActionsSuccess<ActionsExport>>, Expand<ActionsFailure<ActionsExport>>>
export type ActionData = Expand<Kit.AwaitedActions<ActionsExport>> | null;
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('./proxy+page.server.js').load>>>>>>;
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
export type PageServerData = null;
export type PageData = Expand<PageParentData>;
export type PageProps = { params: RouteParams; data: PageData }
@@ -1,87 +0,0 @@
// @ts-nocheck
import { error, fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqItem } from "$lib/types/items";
import { parseBool, parseNum } from "$lib/parse-form";
import { validateImageUpload } from "$lib/validate-image-upload";
const COLLECTION = "Stackq_Items";
export const load = async ({ params, locals }: Parameters<PageServerLoad>[0]) => {
const id = params.id;
if (!id) throw error(404, "Item not found");
try {
const item = await locals.pb
.collection(COLLECTION)
.getOne<StackqItem>(id, { expand: "Parent" });
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqItem>({ sort: "-created", expand: "Parent" })
.catch(() => [] as StackqItem[]);
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { item, itemsForParent };
} catch {
throw error(404, "Item not found");
}
};
export const actions = {
update: async ({ request, locals, params }: import('./$types').RequestEvent) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "update" });
const form = await request.formData();
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
const Image = form.get("Image") as File | null;
const imageFile = Image && Image.size > 0 ? Image : undefined;
if (imageFile) {
const imgError = validateImageUpload(imageFile);
if (imgError) return fail(400, { error: imgError, form: "update" });
}
if (!Item) return fail(400, { error: "Item name is required", form: "update" });
try {
const body: Record<string, unknown> = {
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
};
if (imageFile) body.Image = imageFile;
await locals.pb.collection(COLLECTION).update(id, body);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Update failed";
return fail(500, { error: message, form: "update" });
}
return { success: true };
},
delete: async ({ locals, params }: import('./$types').RequestEvent) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "delete" });
try {
await locals.pb.collection(COLLECTION).delete(id);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Delete failed";
return fail(500, { error: message, form: "delete" });
}
throw redirect(303, "/items");
},
};
;null as any as Actions;
@@ -1,134 +0,0 @@
// @ts-nocheck
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqItem } from "$lib/types/items";
import { parseBool, parseNum } from "$lib/parse-form";
import { validateImageUpload } from "$lib/validate-image-upload";
const COLLECTION = "Stackq_Items";
export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqItem>({
sort: "-created",
expand: "Parent",
})
.catch((err) => {
console.error(COLLECTION, err);
return [] as StackqItem[];
});
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { items: list, itemsForParent };
};
export const actions = {
create: async ({ request, locals }: import('./$types').RequestEvent) => {
const form = await request.formData();
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
const imageFileCreate = form.get("Image") as File | null;
const hasImage = imageFileCreate && imageFileCreate.size > 0;
if (hasImage) {
const imgError = validateImageUpload(imageFileCreate);
if (imgError) return fail(400, { error: imgError, form: "create" });
}
if (!Item) return fail(400, { error: "Item name is required", form: "create" });
try {
const body: Record<string, unknown> = {
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
};
if (hasImage) body.Image = imageFileCreate;
await locals.pb.collection(COLLECTION).create(body);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Create failed";
return fail(500, { error: message, form: "create" });
}
return { success: true };
},
update: async ({ request, locals }: import('./$types').RequestEvent) => {
const form = await request.formData();
const id = (form.get("id") as string)?.trim();
if (!id) return fail(400, { error: "Missing id", form: "update" });
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
const Image = form.get("Image") as File | null;
const imageFile = Image && Image.size > 0 ? Image : undefined;
if (imageFile) {
const imgError = validateImageUpload(imageFile);
if (imgError) return fail(400, { error: imgError, form: "update" });
}
if (!Item) return fail(400, { error: "Item name is required", form: "update" });
try {
const body: Record<string, unknown> = {
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
};
if (imageFile) body.Image = imageFile;
await locals.pb.collection(COLLECTION).update(id, body);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Update failed";
return fail(500, { error: message, form: "update" });
}
return { success: true };
},
delete: async ({ request, locals }: import('./$types').RequestEvent) => {
const form = await request.formData();
const id = (form.get("id") as string)?.trim();
if (!id) return fail(400, { error: "Missing id", form: "delete" });
try {
await locals.pb.collection(COLLECTION).delete(id);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Delete failed";
return fail(500, { error: message, form: "delete" });
}
return { success: true };
},
};
;null as any as Actions;
@@ -1,17 +1,8 @@
// @ts-nocheck
import type { Actions, PageServerLoad } from "./$types";
import type { StackqItem } from "$lib/types/items";
const COLLECTION = "Stackq_Items";
export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqItem>({ sort: "-created", expand: "Parent" })
.catch(() => [] as StackqItem[]);
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { items: list, itemsForParent };
export const load = async () => {
return {};
};
export const actions = {
@@ -31,4 +22,4 @@ export const actions = {
}
},
};
;null as any as Actions;
;null as any as PageServerLoad;;null as any as Actions;
+3 -10
View File
@@ -11,15 +11,8 @@ type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Pa
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = EnsureDefined<import('../$types.js').LayoutServerData>;
type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
export type ActionData = unknown;
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('./proxy+page.server.js').load>>>>>>;
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
export type PageServerData = null;
export type PageData = Expand<PageParentData>;
export type PageProps = { params: RouteParams; data: PageData }
@@ -1,7 +0,0 @@
// @ts-nocheck
import type { PageServerLoad } from "./$types";
export const load = async () => {
return { transactions: [] as { id: string }[] };
};
;null as any as PageServerLoad;
-182
View File
@@ -1,182 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
let {
open = $bindable(false),
onResult,
title = "Scan barcode",
}: {
open?: boolean;
onResult?: (value: string) => void;
title?: string;
} = $props();
let scanError = $state<string | null>(null);
let searchInput = $state("");
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
let stream = $state<MediaStream | null>(null);
let scanRAF = $state(0);
function submitSearch() {
const value = searchInput.trim();
if (!value) return;
stopScan();
open = false;
searchInput = "";
onResult?.(value);
}
async function ensureBarcodeDetector(): Promise<boolean> {
if (typeof globalThis === "undefined" || "BarcodeDetector" in globalThis) return true;
try {
const mod = await import("barcode-detector-api-polyfill");
(globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector =
mod.BarcodeDetector;
return true;
} catch {
return false;
}
}
async function startScan() {
scanError = null;
const hasDetector = await ensureBarcodeDetector();
if (!hasDetector) {
scanError = "Failed to load barcode scanner. Try again or use Chrome/Edge.";
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
} catch (e) {
scanError = e instanceof Error ? e.message : "Could not access camera.";
}
}
function stopScan() {
if (scanRAF) {
cancelAnimationFrame(scanRAF);
scanRAF = 0;
}
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
if (videoEl?.srcObject) {
videoEl.srcObject = null;
}
scanError = null;
}
$effect(() => {
if (!open) {
stopScan();
searchInput = "";
return;
}
startScan();
return () => stopScan();
});
$effect(() => {
if (!open || !stream || !videoEl) return;
videoEl.srcObject = stream;
videoEl.play().catch(() => {});
return () => {
if (videoEl?.srcObject) videoEl.srcObject = null;
};
});
const SCAN_INTERVAL_MS = 300;
function scanLoop() {
if (!videoEl || !("BarcodeDetector" in globalThis)) return;
const detector = new (globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector();
let lastScan = 0;
async function detect() {
if (!open || !videoEl) return;
if (videoEl.readyState < 2) {
scanRAF = requestAnimationFrame(detect);
return;
}
const now = Date.now();
if (now - lastScan >= SCAN_INTERVAL_MS) {
lastScan = now;
try {
const barcodes = await detector.detect(videoEl);
if (barcodes.length > 0) {
const value = barcodes[0].rawValue?.trim() ?? "";
stopScan();
open = false;
onResult?.(value);
return;
}
} catch {
// ignore
}
}
scanRAF = requestAnimationFrame(detect);
}
detect();
}
$effect(() => {
if (!open || !stream || !videoEl) return;
scanLoop();
return () => {
if (scanRAF) cancelAnimationFrame(scanRAF);
};
});
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="flex max-h-[100dvh] w-[100vw] max-w-none flex-col gap-4 rounded-none border-0 p-4 sm:max-h-[90dvh] sm:w-auto sm:max-w-md sm:rounded-lg sm:border"
onclose={() => stopScan()}
>
<Dialog.Header class="shrink-0">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>Scan a barcode or type a code to look up an item.</Dialog.Description>
</Dialog.Header>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div class="flex shrink-0 gap-2">
<Input
type="text"
placeholder="Type barcode or SKU to search…"
bind:value={searchInput}
onkeydown={(e) => e.key === "Enter" && submitSearch()}
class="min-h-11 flex-1"
/>
<Button type="button" class="min-h-11 shrink-0" onclick={submitSearch} disabled={!searchInput.trim()}>
Search
</Button>
</div>
<p class="text-muted-foreground shrink-0 text-center text-xs">or use camera below</p>
{#if scanError}
<p class="text-destructive text-sm">{scanError}</p>
{:else if stream}
<video
bind:this={videoEl}
class="border-input max-h-[50vh] w-full flex-1 rounded-lg border object-cover sm:max-h-none"
muted
playsinline
></video>
{:else}
<p class="text-muted-foreground text-sm">Starting camera…</p>
{/if}
<div class="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
class="min-h-12 flex-1"
onclick={() => { stopScan(); open = false; }}
>
Cancel
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
-34
View File
@@ -1,34 +0,0 @@
/** UOM options for Stackq_Items (single select). */
export const UOM_OPTIONS = ["EA", "BX", "CA", "PK", "PLT", "LF"] as const;
/** Catagory options (single select in schema). */
export const CATAGORY_OPTIONS = [
"Framing",
"Insulation",
"Drywall",
"T-Bar",
"Painting",
"Masking",
"Misc",
] as const;
/** Sub_Catagory options (single select in schema). */
export const SUB_CATAGORY_OPTIONS = [
"ACT",
"Channel",
"Fasteners",
"Finishing Materials",
"Adhesives",
"Mud",
"Corner Bead",
"Sanding",
"Tape",
"Metal Studs",
"Metal Tracks",
"FRP",
"Gyp",
"Insulation",
"Paint",
"Weatherproofing",
"Misc",
] as const;
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Parse form values. Shared by items and receipts server actions.
* Parse form values. Used by receipts server actions.
*/
export function parseNum(val: unknown): number | undefined {
-21
View File
@@ -1,21 +0,0 @@
/** PocketBase Stackq_Items collection record (and expanded relation). */
export interface StackqItem {
id: string;
Item: string;
SKU: string;
Description: string;
UOM: string;
Cost: number;
Vendor: string;
Catagory: string | string[];
Sub_Catagory: string | string[];
Has_Parent: boolean;
Parent: string; // relation id
Stock: boolean;
Image?: string; // file filename
created: string;
updated: string;
expand?: {
Parent?: { id: string; Item?: string; SKU?: string };
};
}
+2 -11
View File
@@ -1,16 +1,7 @@
import type { Actions, PageServerLoad } from "./$types";
import type { StackqItem } from "$lib/types/items";
const COLLECTION = "Stackq_Items";
export const load: PageServerLoad = async ({ locals }) => {
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqItem>({ sort: "-created", expand: "Parent" })
.catch(() => [] as StackqItem[]);
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { items: list, itemsForParent };
export const load: PageServerLoad = async () => {
return {};
};
export const actions: Actions = {
+12 -51
View File
@@ -1,55 +1,16 @@
<script lang="ts">
import { browser } from "$app/environment";
import { Button } from "$lib/components/ui/button";
let { data }: { data: { items: { id: string; SKU?: string }[]; itemsForParent: { id: string; Item: string; SKU: string }[] } } = $props();
</script>
{#if browser}
{#await import("./HomeWithDialog.svelte")}
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6">
<p class="text-muted-foreground">Loading…</p>
</main>
{:then { default: HomeWithDialog }}
<HomeWithDialog data={data} />
{/await}
{:else}
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6">
<h1 class="text-2xl font-semibold">Stackq</h1>
<div class="flex w-full max-w-xs flex-col gap-3">
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>Scan</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>New Transaction</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
<a href="/receipts" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">New receipt</Button>
</a>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>New Item</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
</div>
<div class="text-muted-foreground w-full max-w-xs border-t pt-4 text-center text-sm">Go to</div>
<div class="flex w-full max-w-xs flex-col gap-2">
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>Items</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>Transactions</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
<a href="/receipts" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">Receipts</Button>
</a>
<div class="text-muted-foreground flex h-12 min-h-12 w-full cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 text-sm opacity-60">
<span>Accounts</span>
<span class="text-xs font-medium">Coming soon</span>
</div>
</div>
</main>
{/if}
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6">
<h1 class="text-2xl font-semibold">Stackq</h1>
<p class="text-muted-foreground text-center text-sm">Receipts</p>
<div class="flex w-full max-w-xs flex-col gap-3">
<a href="/receipts/new" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">New receipt</Button>
</a>
<a href="/receipts" class="block">
<Button class="h-12 min-h-12 w-full" variant="outline">Receipts</Button>
</a>
</div>
</main>
-112
View File
@@ -1,112 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import ItemForm from "./items/ItemForm.svelte";
import ReceiptForm from "./receipts/ReceiptForm.svelte";
import BarcodeScanDialog from "$lib/components/BarcodeScanDialog.svelte";
import { goto } from "$app/navigation";
let {
data,
}: {
data: {
items: { id: string; SKU?: string }[];
itemsForParent: { id: string; Item: string; SKU: string }[];
};
} = $props();
let dialogOpen = $state(false);
let receiptDialogOpen = $state(false);
let scanDialogOpen = $state(false);
const items = $derived(data?.items ?? []);
const itemsForParent = $derived(data?.itemsForParent ?? []);
function onItemSuccess() {
dialogOpen = false;
goto("/items");
}
function onReceiptSuccess() {
receiptDialogOpen = false;
goto("/receipts");
}
function onScanResult(value: string) {
const sku = value.trim();
const found = items.find((i) => (i.SKU ?? "").trim() === sku);
if (found) goto("/items/" + found.id);
else goto("/items?q=" + encodeURIComponent(sku));
}
</script>
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-4 sm:gap-6 sm:p-6">
<h1 class="w-full max-w-xs shrink-0 text-center text-2xl font-semibold">Stackq</h1>
<div class="flex w-full max-w-xs shrink-0 flex-col gap-3">
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>Scan</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>New transaction</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (receiptDialogOpen = true)}>
New receipt
</Button>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>New Item</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
</div>
<div class="w-full max-w-xs shrink-0 border-t pt-4"></div>
<div class="flex w-full max-w-xs shrink-0 flex-col gap-2">
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>Items</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>Transactions</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
<a href="/receipts" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">Receipts</Button>
</a>
<div class="text-muted-foreground flex h-16 w-full min-h-16 cursor-not-allowed items-center justify-between gap-2 rounded-md border border-input bg-muted/40 px-4 opacity-60">
<span>Accounts</span>
<span class="text-muted-foreground shrink-0 text-xs font-medium">Coming soon</span>
</div>
</div>
</main>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] overflow-y-auto rounded-lg sm:w-full">
<Dialog.Header>
<Dialog.Title>New item</Dialog.Title>
<Dialog.Description>Add a new Stackq item.</Dialog.Description>
</Dialog.Header>
<ItemForm
itemsForParent={itemsForParent}
action="/items?/create"
submitLabel="Create"
onSuccess={onItemSuccess}
/>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={receiptDialogOpen}>
<Dialog.Content class="flex min-h-0 max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] flex-col overflow-hidden rounded-lg sm:w-full">
<Dialog.Header class="shrink-0">
<Dialog.Title>New receipt</Dialog.Title>
<Dialog.Description>Add a new receipt. Optionally attach an image.</Dialog.Description>
</Dialog.Header>
<ReceiptForm
action="/receipts?/create"
submitLabel="Create"
onSuccess={onReceiptSuccess}
/>
</Dialog.Content>
</Dialog.Root>
<BarcodeScanDialog bind:open={scanDialogOpen} onResult={onScanResult} title="Scan to look up item" />
-5
View File
@@ -1,5 +0,0 @@
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async () => {
return { accounts: [] as { id: string }[] };
};
-75
View File
@@ -1,75 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
let { data }: { data: { accounts: { id: string }[] } } = $props();
let searchQuery = $state("");
const accounts = $derived(data?.accounts ?? []);
const filteredAccounts = $derived(
(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return accounts;
return accounts.filter((a) => a.id.toLowerCase().includes(q));
})()
);
</script>
<svelte:head>
<title>Accounts Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3 min-w-0">
<a href="/" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to main">Back</a>
<h1 class="text-xl font-semibold truncate sm:text-2xl">Accounts</h1>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
<div class="w-full sm:w-64 sm:min-w-0">
<Input
type="search"
placeholder="Search accounts…"
bind:value={searchQuery}
class="h-11 w-full sm:h-9"
/>
</div>
<span class="text-muted-foreground text-sm shrink-0">
{filteredAccounts.length}{filteredAccounts.length !== accounts.length ? ` of ${accounts.length}` : ""} accounts
</span>
<a href="/accounts/new" class="block shrink-0">
<Button class="w-full sm:w-auto min-h-11">New account</Button>
</a>
</div>
</div>
{#if filteredAccounts.length === 0}
<Card>
<CardContent class="flex flex-col items-center justify-center gap-4 py-12">
<p class="text-muted-foreground text-center text-sm">
{accounts.length === 0 ? "No accounts yet." : "No accounts match your search."}
</p>
<p class="text-muted-foreground text-center text-xs">
{accounts.length === 0 ? "Create an account to get started." : "Try a different search."}
</p>
<a href="/accounts/new">
<Button variant="outline">New account</Button>
</a>
<a href="/">
<Button variant="ghost">Back to home</Button>
</a>
</CardContent>
</Card>
{:else}
<div class="flex flex-col gap-2">
{#each filteredAccounts as account (account.id)}
<Card>
<CardContent class="p-4">
<a href="/accounts/{account.id}" class="font-medium hover:underline">Account {account.id}</a>
</CardContent>
</Card>
{/each}
</div>
{/if}
</main>
-26
View File
@@ -1,26 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
let { params }: { params: { id: string } } = $props();
</script>
<svelte:head>
<title>Account Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div class="flex items-center gap-3">
<a href="/accounts" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to accounts">Back</a>
<h1 class="text-xl font-semibold sm:text-2xl">Account</h1>
</div>
<Card>
<CardContent class="flex flex-col gap-4 py-8">
<p class="text-muted-foreground text-sm">Account {params.id}</p>
<a href="/accounts">
<Button variant="outline">Back to accounts</Button>
</a>
</CardContent>
</Card>
</main>
-29
View File
@@ -1,29 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
</script>
<svelte:head>
<title>New account Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div class="flex items-center gap-3">
<a href="/accounts" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to accounts">Back</a>
<h1 class="text-xl font-semibold sm:text-2xl">New account</h1>
</div>
<Card>
<CardContent class="flex flex-col gap-4 py-8">
<p class="text-muted-foreground text-center text-sm">Account creation will be available here.</p>
<div class="flex justify-center gap-2">
<a href="/accounts">
<Button variant="outline">Back to accounts</Button>
</a>
<a href="/">
<Button variant="ghost">Home</Button>
</a>
</div>
</CardContent>
</Card>
</main>
-132
View File
@@ -1,132 +0,0 @@
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqItem } from "$lib/types/items";
import { parseBool, parseNum } from "$lib/parse-form";
import { validateImageUpload } from "$lib/validate-image-upload";
const COLLECTION = "Stackq_Items";
export const load: PageServerLoad = async ({ locals }) => {
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqItem>({
sort: "-created",
expand: "Parent",
})
.catch((err) => {
console.error(COLLECTION, err);
return [] as StackqItem[];
});
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { items: list, itemsForParent };
};
export const actions: Actions = {
create: async ({ request, locals }) => {
const form = await request.formData();
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
const imageFileCreate = form.get("Image") as File | null;
const hasImage = imageFileCreate && imageFileCreate.size > 0;
if (hasImage) {
const imgError = validateImageUpload(imageFileCreate);
if (imgError) return fail(400, { error: imgError, form: "create" });
}
if (!Item) return fail(400, { error: "Item name is required", form: "create" });
try {
const body: Record<string, unknown> = {
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
};
if (hasImage) body.Image = imageFileCreate;
await locals.pb.collection(COLLECTION).create(body);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Create failed";
return fail(500, { error: message, form: "create" });
}
return { success: true };
},
update: async ({ request, locals }) => {
const form = await request.formData();
const id = (form.get("id") as string)?.trim();
if (!id) return fail(400, { error: "Missing id", form: "update" });
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
const Image = form.get("Image") as File | null;
const imageFile = Image && Image.size > 0 ? Image : undefined;
if (imageFile) {
const imgError = validateImageUpload(imageFile);
if (imgError) return fail(400, { error: imgError, form: "update" });
}
if (!Item) return fail(400, { error: "Item name is required", form: "update" });
try {
const body: Record<string, unknown> = {
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
};
if (imageFile) body.Image = imageFile;
await locals.pb.collection(COLLECTION).update(id, body);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Update failed";
return fail(500, { error: message, form: "update" });
}
return { success: true };
},
delete: async ({ request, locals }) => {
const form = await request.formData();
const id = (form.get("id") as string)?.trim();
if (!id) return fail(400, { error: "Missing id", form: "delete" });
try {
await locals.pb.collection(COLLECTION).delete(id);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Delete failed";
return fail(500, { error: message, form: "delete" });
}
return { success: true };
},
};
-92
View File
@@ -1,92 +0,0 @@
<script lang="ts">
import { browser } from "$app/environment";
import { page } from "$app/stores";
import { Badge } from "$lib/components/ui/badge";
import { Card, CardContent } from "$lib/components/ui/card";
import type { StackqItem } from "$lib/types/items";
let { data }: { data: { items: StackqItem[]; itemsForParent: { id: string; Item: string; SKU: string }[] } } = $props();
const searchFromUrl = $derived($page.url.searchParams.get("q") ?? "");
function asList(val: string | string[] | undefined): string[] {
if (val == null) return [];
return Array.isArray(val) ? val : [val];
}
function formatDate(iso: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
} catch {
return iso;
}
}
const items = $derived(data?.items ?? []);
const PB_BASE = "https://pocketbase.ccllc.pro";
const COLLECTION = "Stackq_Items";
function imageUrl(item: StackqItem): string | null {
if (!item.Image) return null;
return `${PB_BASE}/api/files/${COLLECTION}/${item.id}/${item.Image}`;
}
</script>
<svelte:head>
<title>Items Stackq</title>
</svelte:head>
{#if browser}
{#await import("./ItemsCrud.svelte")}
<main class="container mx-auto flex flex-col gap-6 px-4 py-8">
<p class="text-muted-foreground">Loading…</p>
</main>
{:then { default: ItemsCrud }}
<ItemsCrud data={data} searchFromUrl={searchFromUrl} />
{/await}
{:else}
<main class="container mx-auto flex flex-col gap-6 px-4 py-8">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Stackq Items</h1>
<span class="text-muted-foreground text-sm">{items.length} items</span>
</div>
{#if items.length === 0}
<Card>
<CardContent class="flex h-32 items-center justify-center">
<p class="text-muted-foreground text-sm">No items.</p>
</CardContent>
</Card>
{:else}
<div class="flex flex-col gap-2">
{#each items as item (item.id)}
<Card class="overflow-hidden">
<CardContent class="flex flex-row items-center gap-3 p-3">
<div class="size-12 shrink-0 overflow-hidden rounded border">
{#if imageUrl(item)}
<img src={imageUrl(item)!} alt="" class="size-full object-cover" loading="lazy" />
{:else}
<span class="text-muted-foreground flex size-full items-center justify-center text-xs"></span>
{/if}
</div>
<div class="min-w-0 flex-1">
<a href="/items/{item.id}" class="font-medium hover:underline">{item.Item ?? "—"}</a>
<span class="text-muted-foreground font-mono text-xs"> · {item.SKU ?? "—"}</span>
{#if item.Description}
<span class="text-muted-foreground ml-1 truncate text-xs">· {item.Description}</span>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2 text-xs">
<Badge variant="outline">{item.UOM ?? "—"}</Badge>
<span class="tabular-nums">{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}</span>
{#if item.Stock}<Badge variant="default" class="text-xs">Stock</Badge>{/if}
</div>
</CardContent>
</Card>
{/each}
</div>
{/if}
</main>
{/if}
-322
View File
@@ -1,322 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { enhance } from "$app/forms";
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
import { UOM_OPTIONS, CATAGORY_OPTIONS, SUB_CATAGORY_OPTIONS } from "$lib/constants/items";
import type { StackqItem } from "$lib/types/items";
const COLLECTION = "Stackq_Items";
function itemImageUrl(recordId: string, filename: string): string {
return `${POCKETBASE_BASE_URL}/api/files/${COLLECTION}/${recordId}/${filename}`;
}
let {
item = null,
itemsForParent = [],
action,
submitLabel,
error,
onSuccess,
}: {
item?: StackqItem | null;
itemsForParent?: { id: string; Item: string; SKU: string }[];
action: string;
submitLabel: string;
error?: string | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const parentOptions = $derived(
item ? itemsForParent.filter((p) => p.id !== item!.id) : itemsForParent
);
const defaultItem = $derived(item?.Item ?? "");
const defaultSku = $derived(item?.SKU ?? "");
const defaultDesc = $derived(item?.Description ?? "");
const defaultUom = $derived(item?.UOM ?? "");
const defaultCost = $derived(item?.Cost != null ? String(item.Cost) : "");
const defaultVendor = $derived(item?.Vendor ?? "");
const defaultCat = $derived(Array.isArray(item?.Catagory) ? item!.Catagory[0] : item?.Catagory ?? "");
const defaultSub = $derived(Array.isArray(item?.Sub_Catagory) ? item!.Sub_Catagory[0] : item?.Sub_Catagory ?? "");
const defaultParent = $derived(item?.Parent ?? "");
const defaultHasParent = $derived(!!item?.Has_Parent);
const defaultStock = $derived(!!item?.Stock);
let skuValue = $state("");
let showScanner = $state(false);
let scanError = $state<string | null>(null);
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
let stream: MediaStream | null = null;
let scanRAF = 0;
$effect(() => {
skuValue = defaultSku;
});
async function ensureBarcodeDetector(): Promise<boolean> {
if (typeof globalThis === "undefined" || "BarcodeDetector" in globalThis) return true;
try {
const mod = await import("barcode-detector-api-polyfill");
(globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector =
mod.BarcodeDetector;
return true;
} catch {
return false;
}
}
async function startScan() {
scanError = null;
const hasDetector = await ensureBarcodeDetector();
if (!hasDetector) {
scanError = "Failed to load barcode scanner. Try again or use Chrome/Edge.";
showScanner = true;
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
showScanner = true;
} catch (e) {
scanError = e instanceof Error ? e.message : "Could not access camera.";
showScanner = true;
}
}
$effect(() => {
if (!showScanner || !stream || !videoEl) return;
videoEl.srcObject = stream;
videoEl.play().then(() => scanLoop()).catch(() => {});
return () => {
if (videoEl.srcObject) videoEl.srcObject = null;
};
});
const SCAN_INTERVAL_MS = 300;
function scanLoop() {
if (!showScanner || !videoEl || !("BarcodeDetector" in globalThis)) return;
const detector = new (globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector();
let lastScan = 0;
async function detect() {
if (!showScanner || !videoEl) return;
if (videoEl.readyState < 2) {
scanRAF = requestAnimationFrame(detect);
return;
}
const now = Date.now();
if (now - lastScan >= SCAN_INTERVAL_MS) {
lastScan = now;
try {
const barcodes = await detector.detect(videoEl);
if (barcodes.length > 0) {
skuValue = barcodes[0].rawValue;
stopScan();
return;
}
} catch {
// ignore frame errors
}
}
scanRAF = requestAnimationFrame(detect);
}
detect();
}
function stopScan() {
showScanner = false;
scanError = null;
if (scanRAF) {
cancelAnimationFrame(scanRAF);
scanRAF = 0;
}
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
if (videoEl && videoEl.srcObject) {
videoEl.srcObject = null;
}
}
</script>
<form
method="POST"
action={action}
enctype="multipart/form-data"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === "success" && result.data?.success) {
onSuccess?.();
}
}}
class="flex flex-col gap-4"
>
{#if isEdit}
<input type="hidden" name="id" value={item!.id} />
{/if}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="Item">Item</Label>
<Input id="Item" name="Item" value={defaultItem} required />
</div>
<div class="space-y-2">
<Label for="SKU">SKU</Label>
<div class="flex gap-2">
<Input id="SKU" name="SKU" bind:value={skuValue} class="flex-1" />
<Button type="button" variant="outline" onclick={startScan} title="Scan barcode">Scan</Button>
</div>
</div>
</div>
{#if showScanner}
<div
class="bg-background/95 fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 p-4 backdrop-blur-sm"
role="dialog"
aria-label="Scan barcode"
>
<div class="flex w-full max-w-sm flex-col gap-2">
{#if scanError}
<p class="text-destructive text-sm">{scanError}</p>
{:else}
<video
bind:this={videoEl}
class="border-input w-full rounded-lg border"
muted
playsinline
></video>
{/if}
<div class="flex gap-2">
<Button type="button" variant="outline" class="flex-1" onclick={stopScan}>
Cancel
</Button>
</div>
</div>
</div>
{/if}
<div class="space-y-2">
<Label for="Description">Description</Label>
<Input id="Description" name="Description" value={defaultDesc} />
</div>
<div class="space-y-2">
<Label for="Image">Image</Label>
{#if isEdit && item?.Image}
<div class="flex flex-wrap items-center gap-3">
<img
src={itemImageUrl(item!.id, item!.Image)}
alt=""
class="size-20 rounded border object-cover"
/>
<span class="text-muted-foreground text-sm">Replace:</span>
</div>
{/if}
<Input id="Image" name="Image" type="file" accept="image/*" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="UOM">UOM</Label>
<select
id="UOM"
name="UOM"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each UOM_OPTIONS as o}
<option value={o} selected={defaultUom === o}>{o}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="Cost">Cost</Label>
<Input id="Cost" name="Cost" type="number" step="any" value={defaultCost} />
</div>
</div>
<div class="space-y-2">
<Label for="Vendor">Vendor</Label>
<Input id="Vendor" name="Vendor" value={defaultVendor} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="Catagory">Catagory</Label>
<select
id="Catagory"
name="Catagory"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each CATAGORY_OPTIONS as o}
<option value={o} selected={defaultCat === o}>{o}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="Sub_Catagory">Sub_Catagory</Label>
<select
id="Sub_Catagory"
name="Sub_Catagory"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each SUB_CATAGORY_OPTIONS as o}
<option value={o} selected={defaultSub === o}>{o}</option>
{/each}
</select>
</div>
</div>
<div class="space-y-2">
<Label for="Parent">Parent</Label>
<select
id="Parent"
name="Parent"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
<option value=""></option>
{#each parentOptions as p}
<option value={p.id} selected={defaultParent === p.id}>
{p.Item || p.SKU || p.id}
</option>
{/each}
</select>
</div>
<div class="flex flex-wrap gap-6">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="Has_Parent"
value="on"
checked={defaultHasParent}
class="border-input size-4 rounded"
/>
Has Parent
</label>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="Stock"
value="on"
checked={defaultStock}
class="border-input size-4 rounded"
/>
Stock
</label>
</div>
{#if error}
<p class="text-destructive text-sm">{error}</p>
{/if}
<div class="flex justify-end gap-2">
<Button type="submit">{submitLabel}</Button>
</div>
</form>
-254
View File
@@ -1,254 +0,0 @@
<script lang="ts">
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { Input } from "$lib/components/ui/input";
import { enhance } from "$app/forms";
import { goto } from "$app/navigation";
import BarcodeScanDialog from "$lib/components/BarcodeScanDialog.svelte";
import ItemForm from "./ItemForm.svelte";
import type { StackqItem } from "$lib/types/items";
let {
data,
searchFromUrl = "",
}: {
data: {
items: StackqItem[];
itemsForParent: { id: string; Item: string; SKU: string }[];
};
searchFromUrl?: string;
} = $props();
let dialogOpen = $state(false);
let scanDialogOpen = $state(false);
let editingItem = $state<StackqItem | null>(null);
let formWhich = $state<"create" | "update">("create");
let searchQuery = $state("");
$effect(() => {
searchQuery = searchFromUrl;
});
const items = $derived(data?.items ?? []);
const itemsForParent = $derived(data?.itemsForParent ?? []);
const filteredItems = $derived(
(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return items;
return items.filter((i) => {
const item = (i.Item ?? "").toLowerCase();
const sku = (i.SKU ?? "").toLowerCase();
const desc = (i.Description ?? "").toLowerCase();
const vendor = (i.Vendor ?? "").toLowerCase();
const cat = Array.isArray(i.Catagory) ? i.Catagory.join(" ").toLowerCase() : (i.Catagory ?? "").toLowerCase();
const sub = Array.isArray(i.Sub_Catagory) ? i.Sub_Catagory.join(" ").toLowerCase() : (i.Sub_Catagory ?? "").toLowerCase();
return item.includes(q) || sku.includes(q) || desc.includes(q) || vendor.includes(q) || cat.includes(q) || sub.includes(q);
});
})()
);
const PB_BASE = "https://pocketbase.ccllc.pro";
const COLLECTION = "Stackq_Items";
function imageUrl(item: StackqItem): string | null {
if (!item.Image) return null;
return `${PB_BASE}/api/files/${COLLECTION}/${item.id}/${item.Image}`;
}
function asList(val: string | string[] | undefined): string[] {
if (val == null) return [];
return Array.isArray(val) ? val : [val];
}
function formatDate(iso: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
} catch {
return iso;
}
}
function openCreate() {
editingItem = null;
formWhich = "create";
dialogOpen = true;
}
function openEdit(item: StackqItem) {
editingItem = item;
formWhich = "update";
dialogOpen = true;
}
function onFormSuccess() {
dialogOpen = false;
editingItem = null;
}
function onScanResult(value: string) {
const sku = value.trim();
const found = items.find((i) => (i.SKU ?? "").trim() === sku);
if (found) goto("/items/" + found.id);
else goto("/items?q=" + encodeURIComponent(sku));
}
const formError = $derived(
data?.form === "create" || data?.form === "update" ? (data?.error as string | undefined) ?? null : null
);
$effect(() => {
if (formError && (data?.form === "create" || data?.form === "update")) {
dialogOpen = true;
}
});
$effect(() => {
if (searchFromUrl) searchQuery = searchFromUrl;
});
</script>
<svelte:head>
<title>Items Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-4 px-4 py-6 sm:gap-6 sm:px-6 sm:py-8 md:max-w-full">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2 min-w-0">
<a href="/" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to main">Back</a>
<h1 class="text-xl font-semibold truncate sm:text-2xl">Stackq Items</h1>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
<div class="w-full sm:w-64 md:w-80 sm:min-w-0">
<Input
type="search"
placeholder="Search items, SKU…"
bind:value={searchQuery}
class="h-11 w-full sm:h-9"
/>
</div>
<span class="text-muted-foreground text-sm shrink-0">
{filteredItems.length}{filteredItems.length !== items.length ? ` of ${items.length}` : ""} items
</span>
<div class="flex gap-2">
<Button variant="outline" onclick={() => (scanDialogOpen = true)} title="Scan barcode to look up item" class="min-h-11 flex-1 sm:flex-none">
Scan
</Button>
<Button onclick={openCreate} class="min-h-11 flex-1 sm:flex-none">
New item
</Button>
</div>
</div>
</div>
{#if filteredItems.length === 0}
<Card>
<CardContent class="flex h-32 items-center justify-center">
<p class="text-muted-foreground text-center text-sm">
{items.length === 0
? "No items. Create one or check PocketBase collection name and auth."
: "No items match your search."}
</p>
</CardContent>
</Card>
{:else}
<div class="flex flex-col gap-2">
{#each filteredItems as item (item.id)}
<Card class="overflow-hidden">
<CardContent class="flex flex-row items-center gap-3 p-3 md:gap-4 md:px-4 md:py-3">
<div class="size-12 shrink-0 overflow-hidden rounded border">
{#if imageUrl(item)}
<a
href={imageUrl(item)!}
target="_blank"
rel="noopener noreferrer"
class="block size-full"
>
<img
src={imageUrl(item)!}
alt=""
class="size-full object-cover"
loading="lazy"
/>
</a>
{:else}
<span class="text-muted-foreground flex size-full items-center justify-center text-xs"></span>
{/if}
</div>
<div class="min-w-0 flex-1">
<a href="/items/{item.id}" class="font-medium hover:underline">{item.Item ?? "—"}</a>
<span class="text-muted-foreground font-mono text-xs"> · {item.SKU ?? "—"}</span>
{#if item.Description}
<span class="text-muted-foreground ml-1 truncate text-xs">· {item.Description}</span>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2 text-xs">
<Badge variant="outline">{item.UOM ?? "—"}</Badge>
<span class="tabular-nums">{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}</span>
{#if item.Stock}<Badge variant="default" class="text-xs">Stock</Badge>{/if}
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-0 bg-transparent hover:bg-muted inline-flex min-h-9 min-w-9 shrink-0 items-center justify-center rounded-md text-sm"
>
Actions
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => goto("/items/" + item.id)}>View</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openEdit(item)}>Edit</DropdownMenu.Item>
<form
id="delete-form-{item.id}"
method="POST"
action="?/delete"
use:enhance
class="hidden"
>
<input type="hidden" name="id" value={item.id} />
</form>
<DropdownMenu.Item
class="text-destructive focus:text-destructive cursor-pointer"
onclick={() => {
if (confirm("Delete this item? This cannot be undone.")) {
document.getElementById("delete-form-" + item.id)?.requestSubmit();
}
}}
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</CardContent>
</Card>
{/each}
</div>
{/if}
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] overflow-y-auto rounded-lg sm:w-full">
<Dialog.Header>
<Dialog.Title>{editingItem ? "Edit item" : "New item"}</Dialog.Title>
<Dialog.Description>
{editingItem ? "Update the item below." : "Add a new Stackq item."}
</Dialog.Description>
</Dialog.Header>
<ItemForm
item={editingItem}
itemsForParent={itemsForParent}
action={editingItem ? "?/update" : "?/create"}
submitLabel={editingItem ? "Update" : "Create"}
error={formError ?? null}
onSuccess={onFormSuccess}
/>
</Dialog.Content>
</Dialog.Root>
<BarcodeScanDialog bind:open={scanDialogOpen} onResult={onScanResult} title="Scan to look up item" />
</main>
-85
View File
@@ -1,85 +0,0 @@
import { error, fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqItem } from "$lib/types/items";
import { parseBool, parseNum } from "$lib/parse-form";
import { validateImageUpload } from "$lib/validate-image-upload";
const COLLECTION = "Stackq_Items";
export const load: PageServerLoad = async ({ params, locals }) => {
const id = params.id;
if (!id) throw error(404, "Item not found");
try {
const item = await locals.pb
.collection(COLLECTION)
.getOne<StackqItem>(id, { expand: "Parent" });
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqItem>({ sort: "-created", expand: "Parent" })
.catch(() => [] as StackqItem[]);
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
return { item, itemsForParent };
} catch {
throw error(404, "Item not found");
}
};
export const actions: Actions = {
update: async ({ request, locals, params }) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "update" });
const form = await request.formData();
const Item = (form.get("Item") as string)?.trim() ?? "";
const SKU = (form.get("SKU") as string)?.trim() ?? "";
const Description = (form.get("Description") as string)?.trim() ?? "";
const UOM = (form.get("UOM") as string)?.trim() ?? "";
const Cost = parseNum(form.get("Cost"));
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
const Has_Parent = parseBool(form.get("Has_Parent"));
const Parent = (form.get("Parent") as string)?.trim() ?? "";
const Stock = parseBool(form.get("Stock"));
const Image = form.get("Image") as File | null;
const imageFile = Image && Image.size > 0 ? Image : undefined;
if (imageFile) {
const imgError = validateImageUpload(imageFile);
if (imgError) return fail(400, { error: imgError, form: "update" });
}
if (!Item) return fail(400, { error: "Item name is required", form: "update" });
try {
const body: Record<string, unknown> = {
Item,
SKU,
Description,
UOM: UOM || undefined,
Cost: Cost ?? 0,
Vendor,
Catagory: Catagory || undefined,
Sub_Catagory: Sub_Catagory || undefined,
Has_Parent: !!Has_Parent,
Parent: Parent || null,
Stock: !!Stock,
};
if (imageFile) body.Image = imageFile;
await locals.pb.collection(COLLECTION).update(id, body);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Update failed";
return fail(500, { error: message, form: "update" });
}
return { success: true };
},
delete: async ({ locals, params }) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "delete" });
try {
await locals.pb.collection(COLLECTION).delete(id);
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Delete failed";
return fail(500, { error: message, form: "delete" });
}
throw redirect(303, "/items");
},
};
-212
View File
@@ -1,212 +0,0 @@
<script lang="ts">
import { browser } from "$app/environment";
import { Button } from "$lib/components/ui/button";
import type { StackqItem } from "$lib/types/items";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog";
import { enhance } from "$app/forms";
import ItemForm from "../ItemForm.svelte";
let {
data,
}: {
data: {
item: StackqItem;
itemsForParent: { id: string; Item: string; SKU: string }[];
};
} = $props();
let dialogOpen = $state(false);
const item = $derived(data?.item);
const itemsForParent = $derived(data?.itemsForParent ?? []);
const PB_BASE = "https://pocketbase.ccllc.pro";
const COLLECTION = "Stackq_Items";
function imageUrl(i: StackqItem): string | null {
if (!i?.Image) return null;
return `${PB_BASE}/api/files/${COLLECTION}/${i.id}/${i.Image}`;
}
function asList(val: string | string[] | undefined): string[] {
if (val == null) return [];
return Array.isArray(val) ? val : [val];
}
function formatDate(iso: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
} catch {
return iso;
}
}
const formError = $derived(
data?.form === "update" ? (data?.error as string | undefined) ?? null : null
);
$effect(() => {
if (formError && data?.form === "update") dialogOpen = true;
});
</script>
<svelte:head>
<title>{item?.Item ?? "Item"} Stackq</title>
</svelte:head>
{#if item}
<main class="container mx-auto flex flex-col gap-4 px-4 py-6 sm:gap-6 sm:px-6 sm:py-8">
<div class="flex flex-wrap items-center gap-2 sm:gap-4 min-w-0">
<a href="/" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to main">Home</a>
<a href="/items" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to items">Back to items</a>
<h1 class="text-xl font-semibold truncate min-w-0 flex-1 sm:text-2xl">{item.Item ?? "—"}</h1>
{#if browser}
<div class="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onclick={() => (dialogOpen = true)} class="min-h-11 flex-1 sm:flex-none">
Edit
</Button>
<form
id="delete-form-detail"
method="POST"
action="?/delete"
use:enhance
class="inline"
>
<Button
type="button"
variant="destructive"
class="min-h-11 flex-1 sm:flex-none"
onclick={() => {
if (confirm("Delete this item? This cannot be undone.")) {
document.getElementById("delete-form-detail")?.requestSubmit();
}
}}
>
Delete
</Button>
</form>
</div>
{/if}
</div>
<div class="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<h2 class="text-lg font-medium">Details</h2>
</CardHeader>
<CardContent class="space-y-4">
{#if imageUrl(item)}
<div>
<a
href={imageUrl(item)!}
target="_blank"
rel="noopener noreferrer"
class="block overflow-hidden rounded-lg border"
>
<img
src={imageUrl(item)!}
alt=""
class="max-h-64 w-full object-contain"
/>
</a>
</div>
{/if}
<dl class="grid gap-2 text-sm">
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">SKU</dt>
<dd class="font-mono">{item.SKU ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Description</dt>
<dd>{item.Description ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">UOM</dt>
<dd>{item.UOM ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Cost</dt>
<dd>{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Vendor</dt>
<dd>{item.Vendor ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Category</dt>
<dd>
{#each asList(item.Catagory) as cat}
<span class="mr-1 rounded bg-muted px-1.5 py-0.5 text-xs">{cat}</span>
{/each}
{#if asList(item.Catagory).length === 0}{/if}
</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Sub-category</dt>
<dd>
{#each asList(item.Sub_Catagory) as sub}
<span class="mr-1 rounded border px-1.5 py-0.5 text-xs">{sub}</span>
{/each}
{#if asList(item.Sub_Catagory).length === 0}{/if}
</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Has Parent</dt>
<dd>{item.Has_Parent ? "Yes" : "No"}</dd>
</div>
{#if item.Has_Parent && item.expand?.Parent}
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Parent</dt>
<dd>
<a href="/items/{item.Parent}" class="text-primary hover:underline">
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent}
</a>
</dd>
</div>
{/if}
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Stock</dt>
<dd>{item.Stock ? "Yes" : "No"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Created</dt>
<dd>{formatDate(item.created)}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Updated</dt>
<dd>{formatDate(item.updated)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
</main>
{#if browser}
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] overflow-y-auto rounded-lg sm:w-full">
<Dialog.Header>
<Dialog.Title>Edit item</Dialog.Title>
<Dialog.Description>Update the item below.</Dialog.Description>
</Dialog.Header>
<ItemForm
item={item}
itemsForParent={itemsForParent}
action="?/update"
submitLabel="Update"
error={formError ?? null}
onSuccess={() => (dialogOpen = false)}
/>
</Dialog.Content>
</Dialog.Root>
{/if}
{:else}
<main class="container mx-auto py-8">
<p class="text-muted-foreground">Item not found.</p>
</main>
{/if}
-5
View File
@@ -1,5 +0,0 @@
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async () => {
return { transactions: [] as { id: string }[] };
};
-75
View File
@@ -1,75 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
let { data }: { data: { transactions: { id: string }[] } } = $props();
let searchQuery = $state("");
const transactions = $derived(data?.transactions ?? []);
const filteredTransactions = $derived(
(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return transactions;
return transactions.filter((t) => t.id.toLowerCase().includes(q));
})()
);
</script>
<svelte:head>
<title>Transactions Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3 min-w-0">
<a href="/" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to main">Back</a>
<h1 class="text-xl font-semibold truncate sm:text-2xl">Transactions</h1>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
<div class="w-full sm:w-64 sm:min-w-0">
<Input
type="search"
placeholder="Search transactions…"
bind:value={searchQuery}
class="h-11 w-full sm:h-9"
/>
</div>
<span class="text-muted-foreground text-sm shrink-0">
{filteredTransactions.length}{filteredTransactions.length !== transactions.length ? ` of ${transactions.length}` : ""} transactions
</span>
<a href="/transactions/new" class="block shrink-0">
<Button class="w-full sm:w-auto min-h-11">New transaction</Button>
</a>
</div>
</div>
{#if filteredTransactions.length === 0}
<Card>
<CardContent class="flex flex-col items-center justify-center gap-4 py-12">
<p class="text-muted-foreground text-center text-sm">
{transactions.length === 0 ? "No transactions yet." : "No transactions match your search."}
</p>
<p class="text-muted-foreground text-center text-xs">
{transactions.length === 0 ? "Create a transaction to get started." : "Try a different search."}
</p>
<a href="/transactions/new">
<Button variant="outline">New transaction</Button>
</a>
<a href="/">
<Button variant="ghost">Back to home</Button>
</a>
</CardContent>
</Card>
{:else}
<div class="flex flex-col gap-2">
{#each filteredTransactions as tx (tx.id)}
<Card>
<CardContent class="p-4">
<a href="/transactions/{tx.id}" class="font-medium hover:underline">Transaction {tx.id}</a>
</CardContent>
</Card>
{/each}
</div>
{/if}
</main>
-26
View File
@@ -1,26 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
let { params }: { params: { id: string } } = $props();
</script>
<svelte:head>
<title>Transaction Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div class="flex items-center gap-3">
<a href="/transactions" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to transactions">Back</a>
<h1 class="text-xl font-semibold sm:text-2xl">Transaction</h1>
</div>
<Card>
<CardContent class="flex flex-col gap-4 py-8">
<p class="text-muted-foreground text-sm">Transaction {params.id}</p>
<a href="/transactions">
<Button variant="outline">Back to transactions</Button>
</a>
</CardContent>
</Card>
</main>
-29
View File
@@ -1,29 +0,0 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
</script>
<svelte:head>
<title>New transaction Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div class="flex items-center gap-3">
<a href="/transactions" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to transactions">Back</a>
<h1 class="text-xl font-semibold sm:text-2xl">New transaction</h1>
</div>
<Card>
<CardContent class="flex flex-col gap-4 py-8">
<p class="text-muted-foreground text-center text-sm">Transaction creation will be available here.</p>
<div class="flex justify-center gap-2">
<a href="/transactions">
<Button variant="outline">Back to transactions</Button>
</a>
<a href="/">
<Button variant="ghost">Home</Button>
</a>
</div>
</CardContent>
</Card>
</main>