Enhance routing and layout in SvelteKit project by adding new routes for accounts and transactions, updating existing routes, and improving layout parameters. Refactor item handling and integrate barcode scanning functionality for better user experience.
This commit is contained in:
@@ -6,16 +6,22 @@ export const nodes = [
|
||||
() => import('./nodes/2'),
|
||||
() => import('./nodes/3'),
|
||||
() => import('./nodes/4'),
|
||||
() => import('./nodes/5')
|
||||
() => import('./nodes/5'),
|
||||
() => import('./nodes/6'),
|
||||
() => import('./nodes/7'),
|
||||
() => import('./nodes/8')
|
||||
];
|
||||
|
||||
export const server_loads = [];
|
||||
|
||||
export const dictionary = {
|
||||
"/": [~2],
|
||||
"/items": [~4],
|
||||
"/login": [~5],
|
||||
"/logout": [~3]
|
||||
"/accounts": [5],
|
||||
"/items": [~6],
|
||||
"/items/[id]": [~7],
|
||||
"/login": [~8],
|
||||
"/logout": [~3],
|
||||
"/transactions": [4]
|
||||
};
|
||||
|
||||
export const hooks = {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default as component } from "../../../../src/routes/items/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/transactions/+page.svelte";
|
||||
@@ -1 +1 @@
|
||||
export { default as component } from "../../../../src/routes/login/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/accounts/+page.svelte";
|
||||
@@ -0,0 +1 @@
|
||||
export { default as component } from "../../../../src/routes/items/+page.svelte";
|
||||
@@ -0,0 +1 @@
|
||||
export { default as component } from "../../../../src/routes/items/[id]/+page.svelte";
|
||||
@@ -0,0 +1 @@
|
||||
export { default as component } from "../../../../src/routes/login/+page.svelte";
|
||||
@@ -21,10 +21,10 @@ export const options = {
|
||||
service_worker: false,
|
||||
service_worker_options: undefined,
|
||||
templates: {
|
||||
app: ({ head, body, assets, nonce, env }) => "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='4' fill='%231e293b'/><text x='16' y='22' font-size='18' font-family='system-ui' fill='%2394a3b8' text-anchor='middle'>S</text></svg>\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n",
|
||||
app: ({ head, body, assets, nonce, env }) => "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='4' fill='%231e293b'/><text x='16' y='22' font-size='18' font-family='system-ui' fill='%2394a3b8' text-anchor='middle'>S</text></svg>\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\" />\n <meta name=\"theme-color\" content=\"#0f172a\" />\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n",
|
||||
error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
|
||||
},
|
||||
version_hash: "6si2m4"
|
||||
version_hash: "ekcm0i"
|
||||
};
|
||||
|
||||
export async function get_hooks() {
|
||||
|
||||
Vendored
+9
-6
@@ -27,18 +27,21 @@ export {};
|
||||
|
||||
declare module "$app/types" {
|
||||
export interface AppTypes {
|
||||
RouteId(): "/" | "/access-denied" | "/items" | "/login" | "/logout";
|
||||
RouteId(): "/" | "/access-denied" | "/accounts" | "/items" | "/items/[id]" | "/login" | "/logout" | "/transactions";
|
||||
RouteParams(): {
|
||||
|
||||
"/items/[id]": { id: string }
|
||||
};
|
||||
LayoutParams(): {
|
||||
"/": Record<string, never>;
|
||||
"/": { id?: string };
|
||||
"/access-denied": Record<string, never>;
|
||||
"/items": Record<string, never>;
|
||||
"/accounts": Record<string, never>;
|
||||
"/items": { id?: string };
|
||||
"/items/[id]": { id: string };
|
||||
"/login": Record<string, never>;
|
||||
"/logout": Record<string, never>
|
||||
"/logout": Record<string, never>;
|
||||
"/transactions": Record<string, never>
|
||||
};
|
||||
Pathname(): "/" | "/items" | "/login" | "/logout";
|
||||
Pathname(): "/" | "/accounts" | "/items" | `/items/${string}` & {} | "/login" | "/logout" | "/transactions";
|
||||
ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
|
||||
Asset(): string & {};
|
||||
}
|
||||
|
||||
@@ -4,10 +4,17 @@
|
||||
"src/routes/+layout.ts",
|
||||
"src/routes/+layout.ts"
|
||||
],
|
||||
"/accounts": [
|
||||
"src/routes/+layout.ts"
|
||||
],
|
||||
"/items": [
|
||||
"src/routes/items/+page.server.ts",
|
||||
"src/routes/+layout.ts"
|
||||
],
|
||||
"/items/[id]": [
|
||||
"src/routes/items/[id]/+page.server.ts",
|
||||
"src/routes/+layout.ts"
|
||||
],
|
||||
"/login": [
|
||||
"src/routes/login/+page.server.ts",
|
||||
"src/routes/+layout.ts"
|
||||
@@ -15,5 +22,8 @@
|
||||
"/logout": [
|
||||
"src/routes/logout/+page.server.ts",
|
||||
"src/routes/+layout.ts"
|
||||
],
|
||||
"/transactions": [
|
||||
"src/routes/+layout.ts"
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -13,8 +13,8 @@ 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 | "/" | "/logout" | "/items" | "/login" | null
|
||||
type LayoutParams = RouteParams & { }
|
||||
type LayoutRouteId = RouteId | "/" | "/logout" | "/transactions" | "/accounts" | "/items" | "/items/[id]" | "/login" | null
|
||||
type LayoutParams = RouteParams & { id?: string }
|
||||
type LayoutParentData = EnsureDefined<{}>;
|
||||
|
||||
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
|
||||
@@ -26,7 +26,7 @@ export type Action<OutputData extends Record<string, any> | void = Record<string
|
||||
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 LayoutServerData = null;
|
||||
export type LayoutLoad<OutputData extends Partial<App.PageData> & Record<string, any> | void = Partial<App.PageData> & Record<string, any> | void> = Kit.Load<LayoutParams, LayoutServerData, LayoutParentData, OutputData, LayoutRouteId>;
|
||||
export type LayoutLoad<OutputData extends OutputDataShape<LayoutParentData> = OutputDataShape<LayoutParentData>> = Kit.Load<LayoutParams, LayoutServerData, LayoutParentData, OutputData, LayoutRouteId>;
|
||||
export type LayoutLoadEvent = Parameters<LayoutLoad>[0];
|
||||
export type LayoutData = Expand<Omit<LayoutParentData, keyof LayoutParentData & EnsureDefined<LayoutServerData>> & OptionalUnion<EnsureDefined<LayoutParentData & EnsureDefined<LayoutServerData>>>>;
|
||||
export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type * as Kit from '@sveltejs/kit';
|
||||
|
||||
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
// @ts-ignore
|
||||
type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
|
||||
type RouteParams = { };
|
||||
type RouteId = '/accounts';
|
||||
type MaybeWithVoid<T> = {} extends T ? T | void : T;
|
||||
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
|
||||
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
|
||||
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 PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
|
||||
|
||||
export type PageServerData = null;
|
||||
export type PageData = Expand<PageParentData>;
|
||||
export type PageProps = { params: RouteParams; data: PageData }
|
||||
@@ -0,0 +1,32 @@
|
||||
import type * as Kit from '@sveltejs/kit';
|
||||
|
||||
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
// @ts-ignore
|
||||
type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
|
||||
type RouteParams = { id: string };
|
||||
type RouteId = '/items/[id]';
|
||||
type MaybeWithVoid<T> = {} extends T ? T | void : T;
|
||||
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
|
||||
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
|
||||
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>;
|
||||
@@ -0,0 +1,91 @@
|
||||
// @ts-nocheck
|
||||
import { error, fail, redirect } from "@sveltejs/kit";
|
||||
import type { Actions, PageServerLoad } from "./$types";
|
||||
import type { StackqItem } from "$lib/types/items";
|
||||
|
||||
const COLLECTION = "Stackq_Items";
|
||||
|
||||
function parseNum(val: unknown): number | undefined {
|
||||
if (val === "" || val === null || val === undefined) return undefined;
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseBool(val: unknown): boolean {
|
||||
return val === "on" || val === "true" || val === true;
|
||||
}
|
||||
|
||||
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 (!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;
|
||||
@@ -11,5 +11,5 @@ export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
|
||||
.catch(() => [] as StackqItem[]);
|
||||
|
||||
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
|
||||
return { itemsForParent };
|
||||
return { items: list, itemsForParent };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type * as Kit from '@sveltejs/kit';
|
||||
|
||||
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
// @ts-ignore
|
||||
type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
|
||||
type RouteParams = { };
|
||||
type RouteId = '/transactions';
|
||||
type MaybeWithVoid<T> = {} extends T ? T | void : T;
|
||||
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
|
||||
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
|
||||
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 PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
|
||||
|
||||
export type PageServerData = null;
|
||||
export type PageData = Expand<PageParentData>;
|
||||
export type PageProps = { params: RouteParams; data: PageData }
|
||||
+4
-1
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='4' fill='%231e293b'/><text x='16' y='22' font-size='18' font-family='system-ui' fill='%2394a3b8' text-anchor='middle'>S</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<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";
|
||||
import { Search, X } from "@lucide/svelte";
|
||||
|
||||
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 class="mr-2 size-4" />
|
||||
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; }}
|
||||
>
|
||||
<X class="mr-2 size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -10,5 +10,5 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
.catch(() => [] as StackqItem[]);
|
||||
|
||||
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
|
||||
return { itemsForParent };
|
||||
return { items: list, itemsForParent };
|
||||
};
|
||||
|
||||
+22
-7
@@ -2,29 +2,44 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
|
||||
let { data }: { data: { itemsForParent: { id: string; Item: string; SKU: string }[] } } = $props();
|
||||
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-screen flex-col items-center justify-center gap-6 p-8">
|
||||
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 sm:p-8">
|
||||
<p class="text-muted-foreground">Loading…</p>
|
||||
</main>
|
||||
{:then { default: HomeWithDialog }}
|
||||
<HomeWithDialog data={data} />
|
||||
{/await}
|
||||
{:else}
|
||||
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-8">
|
||||
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 sm:p-8">
|
||||
<h1 class="text-2xl font-semibold">Stackq</h1>
|
||||
<div class="flex w-full max-w-xs flex-col gap-3">
|
||||
<a href="/items" class="block">
|
||||
<Button class="h-12 w-full min-h-12">Scan</Button>
|
||||
</a>
|
||||
<a href="/transactions" class="block">
|
||||
<Button class="h-12 min-h-12 w-full" variant="outline">New Transaction</Button>
|
||||
</a>
|
||||
<a href="/items" class="block">
|
||||
<Button class="h-12 min-h-12 w-full" variant="outline">New Item</Button>
|
||||
</a>
|
||||
</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">
|
||||
<a href="/items" class="block">
|
||||
<Button class="w-full">Items table</Button>
|
||||
<Button class="h-12 min-h-12 w-full" variant="outline">Items</Button>
|
||||
</a>
|
||||
<a href="/items" class="block">
|
||||
<Button class="w-full" variant="outline">New item</Button>
|
||||
<a href="/transactions" class="block">
|
||||
<Button class="h-12 min-h-12 w-full" variant="outline">Transactions</Button>
|
||||
</a>
|
||||
<a href="/accounts" class="block">
|
||||
<Button class="h-12 min-h-12 w-full" variant="outline">Accounts</Button>
|
||||
</a>
|
||||
<a href="/logout" class="block">
|
||||
<Button class="w-full" variant="outline">Log out</Button>
|
||||
<Button class="h-12 min-h-12 w-full" variant="outline">Log out</Button>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2,40 +2,99 @@
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import ItemForm from "./items/ItemForm.svelte";
|
||||
import BarcodeScanDialog from "$lib/components/BarcodeScanDialog.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { Camera, Package, Receipt, Wallet } from "@lucide/svelte";
|
||||
|
||||
let {
|
||||
data,
|
||||
}: {
|
||||
data: { itemsForParent: { id: string; Item: string; SKU: string }[] };
|
||||
data: {
|
||||
items: { id: string; SKU?: string }[];
|
||||
itemsForParent: { id: string; Item: string; SKU: string }[];
|
||||
};
|
||||
} = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let scanDialogOpen = $state(false);
|
||||
const items = $derived(data?.items ?? []);
|
||||
const itemsForParent = $derived(data?.itemsForParent ?? []);
|
||||
|
||||
function onSuccess() {
|
||||
function onItemSuccess() {
|
||||
dialogOpen = false;
|
||||
goto("/items");
|
||||
}
|
||||
|
||||
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-screen flex-col items-center justify-center gap-6 p-8">
|
||||
<h1 class="text-2xl font-semibold">Stackq</h1>
|
||||
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 sm:p-8">
|
||||
<h1 class="w-full max-w-xs text-center text-2xl font-semibold">Stackq</h1>
|
||||
|
||||
<div class="flex w-full max-w-xs flex-col gap-3">
|
||||
<p class="text-muted-foreground text-center text-sm font-medium">Quick actions</p>
|
||||
<Button class="h-16 min-h-16 w-full" onclick={() => (scanDialogOpen = true)}>
|
||||
<span class="flex w-full flex-col items-center justify-center gap-1">
|
||||
<Camera class="size-5" />
|
||||
<span class="text-sm font-medium leading-none">Scan</span>
|
||||
</span>
|
||||
</Button>
|
||||
<a href="/transactions" class="block">
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline">
|
||||
<span class="flex w-full flex-col items-center justify-center gap-1">
|
||||
<Receipt class="size-5" />
|
||||
<span class="text-sm font-medium leading-none">New Transaction</span>
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (dialogOpen = true)}>
|
||||
<span class="flex w-full flex-col items-center justify-center gap-1">
|
||||
<Package class="size-5" />
|
||||
<span class="text-sm font-medium leading-none">New Item</span>
|
||||
</span>
|
||||
</Button>
|
||||
</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">
|
||||
<a href="/items" class="block">
|
||||
<Button class="w-full">Items table</Button>
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline">
|
||||
<span class="flex w-full flex-col items-center justify-center gap-1">
|
||||
<Package class="size-4" />
|
||||
<span class="text-sm font-medium leading-none">Items</span>
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<a href="/transactions" class="block">
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline">
|
||||
<span class="flex w-full flex-col items-center justify-center gap-1">
|
||||
<Receipt class="size-4" />
|
||||
<span class="text-sm font-medium leading-none">Transactions</span>
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<a href="/accounts" class="block">
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline">
|
||||
<span class="flex w-full flex-col items-center justify-center gap-1">
|
||||
<Wallet class="size-4" />
|
||||
<span class="text-sm font-medium leading-none">Accounts</span>
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
<Button class="w-full" variant="outline" onclick={() => (dialogOpen = true)}>
|
||||
New item
|
||||
</Button>
|
||||
<a href="/logout" class="block">
|
||||
<Button class="w-full" variant="outline">Log out</Button>
|
||||
<Button class="h-16 min-h-16 w-full justify-center" variant="outline">Log out</Button>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[500px]">
|
||||
<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>
|
||||
@@ -44,7 +103,9 @@
|
||||
itemsForParent={itemsForParent}
|
||||
action="/items?/create"
|
||||
submitLabel="Create"
|
||||
onSuccess={onSuccess}
|
||||
onSuccess={onItemSuccess}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<BarcodeScanDialog bind:open={scanDialogOpen} onResult={onScanResult} title="Scan to look up item" />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Home } from "@lucide/svelte";
|
||||
</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 items-center gap-3">
|
||||
<a href="/" class="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to main">
|
||||
<Home class="size-5" />
|
||||
</a>
|
||||
<h1 class="text-xl font-semibold sm:text-2xl">Accounts</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<p class="text-muted-foreground text-center text-sm">No accounts yet.</p>
|
||||
<p class="text-muted-foreground text-center text-xs">Accounts will appear here once this feature is available.</p>
|
||||
<a href="/">
|
||||
<Button variant="outline">Back to home</Button>
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
+41
-107
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
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];
|
||||
@@ -39,120 +41,52 @@
|
||||
|
||||
{#if browser}
|
||||
{#await import("./ItemsCrud.svelte")}
|
||||
<main class="container mx-auto flex flex-col gap-6 py-8">
|
||||
<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} />
|
||||
<ItemsCrud data={data} searchFromUrl={searchFromUrl} />
|
||||
{/await}
|
||||
{:else}
|
||||
<main class="container mx-auto flex flex-col gap-6 py-8">
|
||||
<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>
|
||||
<Card>
|
||||
<CardContent class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Image</Table.Head>
|
||||
<Table.Head>Item</Table.Head>
|
||||
<Table.Head>SKU</Table.Head>
|
||||
<Table.Head class="max-w-[200px]">Description</Table.Head>
|
||||
<Table.Head>UOM</Table.Head>
|
||||
<Table.Head class="text-right">Cost</Table.Head>
|
||||
<Table.Head>Vendor</Table.Head>
|
||||
<Table.Head>Catagory</Table.Head>
|
||||
<Table.Head>Sub_Catagory</Table.Head>
|
||||
<Table.Head>Has Parent</Table.Head>
|
||||
<Table.Head>Parent</Table.Head>
|
||||
<Table.Head>Stock</Table.Head>
|
||||
<Table.Head class="text-muted-foreground">created</Table.Head>
|
||||
<Table.Head class="text-muted-foreground">updated</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if items.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan="14" class="h-24 text-center text-muted-foreground">
|
||||
No items.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each items as item (item.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-16">
|
||||
{#if imageUrl(item)}
|
||||
<img
|
||||
src={imageUrl(item)!}
|
||||
alt=""
|
||||
class="size-14 rounded border object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-xs">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">{item.Item ?? "—"}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{item.SKU ?? "—"}</Table.Cell>
|
||||
<Table.Cell class="max-w-[200px] truncate" title={item.Description ?? ""}>
|
||||
{item.Description ?? "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{item.UOM ?? "—"}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">
|
||||
{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{item.Vendor ?? "—"}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each asList(item.Catagory) as cat}
|
||||
<Badge variant="secondary" class="mr-1 text-xs">{cat}</Badge>
|
||||
{/each}
|
||||
{#if asList(item.Catagory).length === 0}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each asList(item.Sub_Catagory) as sub}
|
||||
<Badge variant="outline" class="mr-1 text-xs">{sub}</Badge>
|
||||
{/each}
|
||||
{#if asList(item.Sub_Catagory).length === 0}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if item.Has_Parent}
|
||||
<Badge>Yes</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">
|
||||
{#if item.expand?.Parent}
|
||||
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent}
|
||||
{:else if item.Parent}
|
||||
<span class="font-mono text-xs">{item.Parent}</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if item.Stock}
|
||||
<Badge variant="default">Stock</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">{formatDate(item.created)}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">{formatDate(item.updated)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{#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}
|
||||
|
||||
+159
-164
@@ -1,31 +1,53 @@
|
||||
<script lang="ts">
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
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 { Ellipsis, Pencil, Plus, Trash2 } from "@lucide/svelte";
|
||||
import { Camera, Ellipsis, ExternalLink, Home, Pencil, Plus, Search, Trash2 } from "@lucide/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(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 {
|
||||
@@ -67,6 +89,13 @@
|
||||
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
|
||||
);
|
||||
@@ -76,179 +105,143 @@
|
||||
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-6 py-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Stackq Items</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">{items.length} items</span>
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="mr-2 size-4" />
|
||||
New item
|
||||
</Button>
|
||||
<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-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="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to main">
|
||||
<Home class="size-5" />
|
||||
</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="relative w-full sm:w-64 sm:min-w-0">
|
||||
<Search class="text-muted-foreground absolute left-3 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search items, SKU…"
|
||||
bind:value={searchQuery}
|
||||
class="h-11 w-full pl-9 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">
|
||||
<Camera class="mr-2 size-4 shrink-0" />
|
||||
Scan
|
||||
</Button>
|
||||
<Button onclick={openCreate} class="min-h-11 flex-1 sm:flex-none">
|
||||
<Plus class="mr-2 size-4 shrink-0" />
|
||||
New item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Image</Table.Head>
|
||||
<Table.Head>Item</Table.Head>
|
||||
<Table.Head>SKU</Table.Head>
|
||||
<Table.Head class="max-w-[200px]">Description</Table.Head>
|
||||
<Table.Head>UOM</Table.Head>
|
||||
<Table.Head class="text-right">Cost</Table.Head>
|
||||
<Table.Head>Vendor</Table.Head>
|
||||
<Table.Head>Catagory</Table.Head>
|
||||
<Table.Head>Sub_Catagory</Table.Head>
|
||||
<Table.Head>Has Parent</Table.Head>
|
||||
<Table.Head>Parent</Table.Head>
|
||||
<Table.Head>Stock</Table.Head>
|
||||
<Table.Head class="text-muted-foreground">created</Table.Head>
|
||||
<Table.Head class="text-muted-foreground">updated</Table.Head>
|
||||
<Table.Head class="w-[70px]">Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if items.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan="15" class="h-24 text-center text-muted-foreground">
|
||||
No items. Create one or check PocketBase collection name and auth.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each items as item (item.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-16">
|
||||
{#if imageUrl(item)}
|
||||
<a
|
||||
href={imageUrl(item)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block overflow-hidden rounded border"
|
||||
>
|
||||
<img
|
||||
src={imageUrl(item)!}
|
||||
alt=""
|
||||
class="size-14 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-xs">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">{item.Item ?? "—"}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{item.SKU ?? "—"}</Table.Cell>
|
||||
<Table.Cell class="max-w-[200px] truncate" title={item.Description ?? ""}>
|
||||
{item.Description ?? "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{item.UOM ?? "—"}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">
|
||||
{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{item.Vendor ?? "—"}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each asList(item.Catagory) as cat}
|
||||
<Badge variant="secondary" class="mr-1 text-xs">{cat}</Badge>
|
||||
{/each}
|
||||
{#if asList(item.Catagory).length === 0}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each asList(item.Sub_Catagory) as sub}
|
||||
<Badge variant="outline" class="mr-1 text-xs">{sub}</Badge>
|
||||
{/each}
|
||||
{#if asList(item.Sub_Catagory).length === 0}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if item.Has_Parent}
|
||||
<Badge>Yes</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">
|
||||
{#if item.expand?.Parent}
|
||||
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent}
|
||||
{:else if item.Parent}
|
||||
<span class="font-mono text-xs">{item.Parent}</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if item.Stock}
|
||||
<Badge variant="default">Stock</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{formatDate(item.created)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{formatDate(item.updated)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="border-0 bg-transparent hover:bg-muted inline-flex size-8 items-center justify-center rounded-md"
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEdit(item)}>
|
||||
<Pencil class="mr-2 size-4" />
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{#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">
|
||||
<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"
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => goto("/items/" + item.id)}>
|
||||
<ExternalLink class="mr-2 size-4" />
|
||||
View
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => openEdit(item)}>
|
||||
<Pencil class="mr-2 size-4" />
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[500px]">
|
||||
<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>
|
||||
@@ -265,4 +258,6 @@
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<BarcodeScanDialog bind:open={scanDialogOpen} onResult={onScanResult} title="Scan to look up item" />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { error, fail, redirect } from "@sveltejs/kit";
|
||||
import type { Actions, PageServerLoad } from "./$types";
|
||||
import type { StackqItem } from "$lib/types/items";
|
||||
|
||||
const COLLECTION = "Stackq_Items";
|
||||
|
||||
function parseNum(val: unknown): number | undefined {
|
||||
if (val === "" || val === null || val === undefined) return undefined;
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseBool(val: unknown): boolean {
|
||||
return val === "on" || val === "true" || val === true;
|
||||
}
|
||||
|
||||
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 (!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");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
<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";
|
||||
import { ArrowLeft, Home, Pencil, Trash2 } from "@lucide/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="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to main">
|
||||
<Home class="size-5" />
|
||||
</a>
|
||||
<a href="/items" class="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to items">
|
||||
<ArrowLeft class="size-5" />
|
||||
</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">
|
||||
<Pencil class="mr-2 size-4" />
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
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}
|
||||
@@ -104,5 +104,38 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Safe area for notched devices */
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
/* Larger tap targets on touch devices (buttons only; links use .touch-target where needed) */
|
||||
@media (pointer: coarse) {
|
||||
button:not([class*="size-"]) {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Use on icon-only links for 44px touch target */
|
||||
.touch-target {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Prevent iOS zoom on input focus (requires font-size >= 16px) */
|
||||
@media (max-width: 640px) {
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="number"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent } from "$lib/components/ui/card";
|
||||
import { Home } from "@lucide/svelte";
|
||||
</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 items-center gap-3">
|
||||
<a href="/" class="touch-target text-muted-foreground hover:text-foreground shrink-0" title="Back to main">
|
||||
<Home class="size-5" />
|
||||
</a>
|
||||
<h1 class="text-xl font-semibold sm:text-2xl">Transactions</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<p class="text-muted-foreground text-center text-sm">No transactions yet.</p>
|
||||
<p class="text-muted-foreground text-center text-xs">Transactions will appear here once this feature is available.</p>
|
||||
<a href="/">
|
||||
<Button variant="outline">Back to home</Button>
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
Reference in New Issue
Block a user