diff --git a/.svelte-kit/generated/client/app.js b/.svelte-kit/generated/client/app.js index 494cf3dd..49406e8f 100644 --- a/.svelte-kit/generated/client/app.js +++ b/.svelte-kit/generated/client/app.js @@ -32,7 +32,7 @@ export const dictionary = { "/logout": [~6], "/receipts": [~3], "/receipts/new": [4], - "/receipts/[id]": [5], + "/receipts/[id]": [~5], "/transactions": [~7], "/transactions/new": [8], "/transactions/[id]": [9] diff --git a/.svelte-kit/generated/server/internal.js b/.svelte-kit/generated/server/internal.js index 47ffc1c4..e3c04eea 100644 --- a/.svelte-kit/generated/server/internal.js +++ b/.svelte-kit/generated/server/internal.js @@ -24,7 +24,7 @@ export const options = { app: ({ head, body, assets, nonce, env }) => "\n\n \n \n S\" />\n \n \n \n \n \n \n \n " + head + "\n \n \n
" + body + "
\n \n\n", error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" }, - version_hash: "16sw9ck" + version_hash: "11efg8s" }; export async function get_hooks() { diff --git a/.svelte-kit/types/route_meta_data.json b/.svelte-kit/types/route_meta_data.json index c9320440..86514602 100644 --- a/.svelte-kit/types/route_meta_data.json +++ b/.svelte-kit/types/route_meta_data.json @@ -49,6 +49,7 @@ "src/routes/+layout.server.ts" ], "/receipts/[id]": [ + "src/routes/receipts/[id]/+page.server.ts", "src/routes/+layout.ts", "src/routes/+layout.server.ts" ], diff --git a/.svelte-kit/types/src/routes/receipts/$types.d.ts b/.svelte-kit/types/src/routes/receipts/$types.d.ts index 968b5fac..431734f8 100644 --- a/.svelte-kit/types/src/routes/receipts/$types.d.ts +++ b/.svelte-kit/types/src/routes/receipts/$types.d.ts @@ -16,7 +16,13 @@ type PageParentData = EnsureDefined; export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; export type PageServerLoadEvent = Parameters[0]; -export type ActionData = unknown; +type ExcludeActionFailure = T extends Kit.ActionFailure ? never : T extends void ? never : T; +type ActionsSuccess any>> = { [Key in keyof T]: ExcludeActionFailure>>; }[keyof T]; +type ExtractActionFailure = T extends Kit.ActionFailure ? X extends void ? never : X : never; +type ActionsFailure any>> = { [Key in keyof T]: Exclude>>, void>; }[keyof T]; +type ActionsExport = typeof import('./proxy+page.server.js').actions +export type SubmitFunction = Kit.SubmitFunction>, Expand>> +export type ActionData = Expand> | null; export type PageServerData = Expand>>>>>; export type PageData = Expand & EnsureDefined>; export type Action | void = Record | void> = Kit.Action diff --git a/.svelte-kit/types/src/routes/receipts/[id]/$types.d.ts b/.svelte-kit/types/src/routes/receipts/[id]/$types.d.ts index 9ed08e12..70426841 100644 --- a/.svelte-kit/types/src/routes/receipts/[id]/$types.d.ts +++ b/.svelte-kit/types/src/routes/receipts/[id]/$types.d.ts @@ -11,9 +11,16 @@ type OutputDataShape = MaybeWithVoid> & Pa type EnsureDefined = T extends null | undefined ? {} : T; type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; export type Snapshot = Kit.Snapshot; +type PageServerParentData = EnsureDefined; type PageParentData = EnsureDefined; export type EntryGenerator = () => Promise> | Array; -export type PageServerData = null; -export type PageData = Expand; -export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file +export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; +export type PageServerLoadEvent = Parameters[0]; +export type ActionData = unknown; +export type PageServerData = Expand>>>>>; +export type PageData = Expand & EnsureDefined>; +export type Action | void = Record | void> = Kit.Action +export type Actions | void = Record | void> = Kit.Actions +export type PageProps = { params: RouteParams; data: PageData; form: ActionData } +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts b/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts index 99c9386e..4c7af177 100644 --- a/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts +++ b/.svelte-kit/types/src/routes/receipts/proxy+page.server.ts @@ -1,8 +1,57 @@ // @ts-nocheck -import type { PageServerLoad } from "./$types"; +import { fail } from "@sveltejs/kit"; +import type { Actions, PageServerLoad } from "./$types"; +import type { StackqReceipt } from "$lib/types/receipts"; -export const load = async () => { - // Placeholder: no PocketBase receipts collection yet. Return empty list. - return { receipts: [] as { id: string }[] }; +const COLLECTION = "Stackq_Receipts"; + +const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const; +const TYPE_OPTIONS = ["Purchase", "Gas"] as const; + +export const load = async ({ locals }: Parameters[0]) => { + const list = await locals.pb + .collection(COLLECTION) + .getFullList({ + sort: "-created", + expand: "User", + }) + .catch((err) => { + console.error(COLLECTION, err); + return [] as StackqReceipt[]; + }); + + return { receipts: list }; }; -;null as any as PageServerLoad; \ No newline at end of file + +export const actions = { + create: async ({ request, locals }: import('./$types').RequestEvent) => { + const user = locals.user; + if (!user?.id) return fail(401, { error: "You must be logged in to create a receipt." }); + + const form = await request.formData(); + const Status = (form.get("Status") as string)?.trim() || "New"; + const Type = (form.get("Type") as string)?.trim() || "Purchase"; + const imageFile = form.get("Image") as File | null; + const hasImage = imageFile && imageFile.size > 0; + + const body: Record = { + Status: STATUS_OPTIONS.includes(Status as (typeof STATUS_OPTIONS)[number]) ? Status : "New", + Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase", + User: user.id, + }; + if (hasImage) body.Image = imageFile; + + try { + await locals.pb.collection(COLLECTION).create(body); + } catch (err: unknown) { + const message = + err && typeof err === "object" && "message" in err + ? String((err as { message: string }).message) + : "Failed to create receipt."; + return fail(500, { error: message, Status, Type }); + } + + return { success: true }; + }, +}; +;null as any as Actions; \ No newline at end of file diff --git a/src/lib/types/receipts.ts b/src/lib/types/receipts.ts new file mode 100644 index 00000000..43d2d1f3 --- /dev/null +++ b/src/lib/types/receipts.ts @@ -0,0 +1,14 @@ +/** PocketBase Stackq_Receipts collection record (and expanded relation). */ +export interface StackqReceipt { + id: string; + Status: string; + Type: string; + User: string; + Image?: string; + Data?: Record; + created: string; + updated: string; + expand?: { + User?: { id: string; email?: string; name?: string }; + }; +} diff --git a/src/routes/HomeWithDialog.svelte b/src/routes/HomeWithDialog.svelte index eec86e7b..db6b1f15 100644 --- a/src/routes/HomeWithDialog.svelte +++ b/src/routes/HomeWithDialog.svelte @@ -2,6 +2,7 @@ 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 { @@ -14,6 +15,7 @@ } = $props(); let dialogOpen = $state(false); + let receiptDialogOpen = $state(false); let scanDialogOpen = $state(false); const items = $derived(data?.items ?? []); const itemsForParent = $derived(data?.itemsForParent ?? []); @@ -23,6 +25,11 @@ 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); @@ -39,9 +46,9 @@ - - - + @@ -78,4 +85,18 @@ + + + + New receipt + Add a new receipt. Optionally attach an image. + + + + + diff --git a/src/routes/receipts/+page.server.ts b/src/routes/receipts/+page.server.ts index db8d6e07..2298aca7 100644 --- a/src/routes/receipts/+page.server.ts +++ b/src/routes/receipts/+page.server.ts @@ -1,6 +1,55 @@ -import type { PageServerLoad } from "./$types"; +import { fail } from "@sveltejs/kit"; +import type { Actions, PageServerLoad } from "./$types"; +import type { StackqReceipt } from "$lib/types/receipts"; -export const load: PageServerLoad = async () => { - // Placeholder: no PocketBase receipts collection yet. Return empty list. - return { receipts: [] as { id: string }[] }; +const COLLECTION = "Stackq_Receipts"; + +const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const; +const TYPE_OPTIONS = ["Purchase", "Gas"] as const; + +export const load: PageServerLoad = async ({ locals }) => { + const list = await locals.pb + .collection(COLLECTION) + .getFullList({ + sort: "-created", + expand: "User", + }) + .catch((err) => { + console.error(COLLECTION, err); + return [] as StackqReceipt[]; + }); + + return { receipts: list }; +}; + +export const actions: Actions = { + create: async ({ request, locals }) => { + const user = locals.user; + if (!user?.id) return fail(401, { error: "You must be logged in to create a receipt." }); + + const form = await request.formData(); + const Status = (form.get("Status") as string)?.trim() || "New"; + const Type = (form.get("Type") as string)?.trim() || "Purchase"; + const imageFile = form.get("Image") as File | null; + const hasImage = imageFile && imageFile.size > 0; + + const body: Record = { + Status: STATUS_OPTIONS.includes(Status as (typeof STATUS_OPTIONS)[number]) ? Status : "New", + Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase", + User: user.id, + }; + if (hasImage) body.Image = imageFile; + + try { + await locals.pb.collection(COLLECTION).create(body); + } catch (err: unknown) { + const message = + err && typeof err === "object" && "message" in err + ? String((err as { message: string }).message) + : "Failed to create receipt."; + return fail(500, { error: message, Status, Type }); + } + + return { success: true }; + }, }; diff --git a/src/routes/receipts/+page.svelte b/src/routes/receipts/+page.svelte index 4577d39e..b786f4db 100644 --- a/src/routes/receipts/+page.svelte +++ b/src/routes/receipts/+page.svelte @@ -2,18 +2,44 @@ import { Button } from "$lib/components/ui/button"; import { Card, CardContent } from "$lib/components/ui/card"; import { Input } from "$lib/components/ui/input"; + import * as Dialog from "$lib/components/ui/dialog"; + import ReceiptForm from "./ReceiptForm.svelte"; + import type { StackqReceipt } from "$lib/types/receipts"; + import type { ActionData } from "./$types"; - let { data }: { data: { receipts: { id: string }[] } } = $props(); + let { data, form }: { data: { receipts: StackqReceipt[] }; form?: ActionData } = $props(); let searchQuery = $state(""); + let createDialogOpen = $state(false); const receipts = $derived(data?.receipts ?? []); const filteredReceipts = $derived( (() => { const q = searchQuery.trim().toLowerCase(); if (!q) return receipts; - return receipts.filter((r) => r.id.toLowerCase().includes(q)); + return receipts.filter( + (r) => + r.id.toLowerCase().includes(q) || + (r.Status ?? "").toLowerCase().includes(q) || + (r.Type ?? "").toLowerCase().includes(q) + ); })() ); + + function formatDate(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { + dateStyle: "short", + timeStyle: "short", + }); + } catch { + return iso; + } + } + + function onReceiptCreateSuccess() { + createDialogOpen = false; + } @@ -38,9 +64,9 @@ {filteredReceipts.length}{filteredReceipts.length !== receipts.length ? ` of ${receipts.length}` : ""} receipts - - - + @@ -53,9 +79,7 @@

{receipts.length === 0 ? "Create a receipt to get started." : "Try a different search."}

- - - + @@ -66,10 +90,30 @@ {#each filteredReceipts as receipt (receipt.id)} - Receipt {receipt.id} + + {receipt.Status ?? "—"} · {receipt.Type ?? "—"} · {formatDate(receipt.created)} + +

ID: {receipt.id}

{/each} {/if} + + + + + New receipt + Add a new receipt. Optionally attach an image. + + + + diff --git a/src/routes/receipts/ReceiptForm.svelte b/src/routes/receipts/ReceiptForm.svelte new file mode 100644 index 00000000..f1efd5a2 --- /dev/null +++ b/src/routes/receipts/ReceiptForm.svelte @@ -0,0 +1,113 @@ + + +
async ({ update, result }) => { + await update(); + if (result.type === "success" && result.data?.success) { + onSuccess?.(); + } + }} + class="flex flex-col gap-4" +> +
+ + +
+ +
+ + +
+ +
+ +
+ + + {#if imageFileName} +

Selected: {imageFileName}

+ {:else} +

Optional. Use “Scan receipt” to take a photo or pick an image.

+ {/if} +
+
+ + {#if error} +

{error}

+ {/if} + +
+ +
+
diff --git a/src/routes/receipts/[id]/+page.server.ts b/src/routes/receipts/[id]/+page.server.ts new file mode 100644 index 00000000..11053671 --- /dev/null +++ b/src/routes/receipts/[id]/+page.server.ts @@ -0,0 +1,23 @@ +import { error } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; +import type { StackqReceipt } from "$lib/types/receipts"; + +const COLLECTION = "Stackq_Receipts"; + +export const load: PageServerLoad = async ({ params, locals }) => { + const { id } = params; + try { + const record = await locals.pb + .collection(COLLECTION) + .getOne(id, { expand: "User" }); + return { receipt: record }; + } catch (err: unknown) { + const is404 = + err && + typeof err === "object" && + "status" in err && + (err as { status: number }).status === 404; + if (is404) throw error(404, "Receipt not found"); + throw err; + } +}; diff --git a/src/routes/receipts/[id]/+page.svelte b/src/routes/receipts/[id]/+page.svelte index cba98000..b19a17b8 100644 --- a/src/routes/receipts/[id]/+page.svelte +++ b/src/routes/receipts/[id]/+page.svelte @@ -1,12 +1,34 @@ - Receipt – Stackq + Receipt {receipt?.Status ?? receipt?.id ?? "—"} – Stackq
@@ -15,12 +37,75 @@

Receipt

- - -

Receipt {params.id}

- - - -
-
+ {#if receipt} + + +
+
+
Status
+
{receipt.Status ?? "—"}
+
+
+
Type
+
{receipt.Type ?? "—"}
+
+
+
User
+
+ {#if receipt.expand?.User} + {receipt.expand.User.name ?? receipt.expand.User.email ?? receipt.expand.User.id} + {:else} + — + {/if} +
+
+
+
Created
+
{formatDate(receipt.created)}
+
+
+
Updated
+
{formatDate(receipt.updated)}
+
+ {#if receipt.Data && Object.keys(receipt.Data).length > 0} +
+
Data
+
+
{JSON.stringify(receipt.Data, null, 2)}
+
+
+ {/if} + {#if receipt.Image} +
+
Image
+
+ {@const url = imageUrl(receipt)} + {#if url} + Receipt + {:else} + — + {/if} +
+
+ {/if} +
+ + + +
+
+ {:else} + + +

Receipt not found.

+ + + +
+
+ {/if}