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.
This commit is contained in:
@@ -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 @@
|
||||
export { default as component } from "../../../../src/routes/accounts/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/login/+page.svelte";
|
||||
@@ -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 @@
|
||||
export { default as component } from "../../../../src/routes/accounts/[id]/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/login/+page.svelte";
|
||||
@@ -1 +1 @@
|
||||
export { default as component } from "../../../../src/routes/items/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/login/+page.svelte";
|
||||
@@ -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 @@
|
||||
export { default as component } from "../../../../src/routes/transactions/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/login/+page.svelte";
|
||||
@@ -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 @@
|
||||
export { default as component } from "../../../../src/routes/transactions/[id]/+page.svelte";
|
||||
export { default as component } from "../../../../src/routes/login/+page.svelte";
|
||||
Vendored
+1
-1
@@ -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 & {};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return { accounts: [] as { id: string }[] };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return { transactions: [] as { id: string }[] };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user