Implement receipt management features including creation, loading, and display of receipts. Refactor related components and enhance UI with dialogs for adding new receipts. Update type definitions and improve error handling for better user experience.
CI / build (push) Has been skipped
CI / deploy (push) Failing after 1m15s

This commit is contained in:
eewing
2026-02-19 09:55:26 -06:00
parent c6b30100bd
commit 567d7e3e13
13 changed files with 449 additions and 37 deletions
+14
View File
@@ -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<string, unknown>;
created: string;
updated: string;
expand?: {
User?: { id: string; email?: string; name?: string };
};
}
+24 -3
View File
@@ -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 @@
<a href="/transactions/new" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">New transaction</Button>
</a>
<a href="/receipts/new" class="block">
<Button class="h-16 min-h-16 w-full" variant="outline">New receipt</Button>
</a>
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (receiptDialogOpen = true)}>
New receipt
</Button>
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (dialogOpen = true)}>New Item</Button>
</div>
@@ -78,4 +85,18 @@
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={receiptDialogOpen}>
<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 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" />
+53 -4
View File
@@ -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<StackqReceipt>({
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<string, unknown> = {
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 };
},
};
+53 -9
View File
@@ -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;
}
</script>
<svelte:head>
@@ -38,9 +64,9 @@
<span class="text-muted-foreground text-sm shrink-0">
{filteredReceipts.length}{filteredReceipts.length !== receipts.length ? ` of ${receipts.length}` : ""} receipts
</span>
<a href="/receipts/new" class="block shrink-0">
<Button class="w-full sm:w-auto min-h-11">New receipt</Button>
</a>
<Button class="w-full sm:w-auto min-h-11" onclick={() => (createDialogOpen = true)}>
New receipt
</Button>
</div>
</div>
@@ -53,9 +79,7 @@
<p class="text-muted-foreground text-center text-xs">
{receipts.length === 0 ? "Create a receipt to get started." : "Try a different search."}
</p>
<a href="/receipts/new">
<Button variant="outline">New receipt</Button>
</a>
<Button variant="outline" onclick={() => (createDialogOpen = true)}>New receipt</Button>
<a href="/">
<Button variant="ghost">Back to home</Button>
</a>
@@ -66,10 +90,30 @@
{#each filteredReceipts as receipt (receipt.id)}
<Card>
<CardContent class="p-4">
<a href="/receipts/{receipt.id}" class="font-medium hover:underline">Receipt {receipt.id}</a>
<a href="/receipts/{receipt.id}" class="font-medium hover:underline">
{receipt.Status ?? "—"} · {receipt.Type ?? "—"} · {formatDate(receipt.created)}
</a>
<p class="text-muted-foreground mt-1 text-xs">ID: {receipt.id}</p>
</CardContent>
</Card>
{/each}
</div>
{/if}
</main>
<Dialog.Root bind:open={createDialogOpen}>
<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 receipt</Dialog.Title>
<Dialog.Description>Add a new receipt. Optionally attach an image.</Dialog.Description>
</Dialog.Header>
<ReceiptForm
action="/receipts?/create"
submitLabel="Create"
error={form?.error}
defaultStatus={form?.Status ?? "New"}
defaultType={form?.Type ?? "Purchase"}
onSuccess={onReceiptCreateSuccess}
/>
</Dialog.Content>
</Dialog.Root>
+113
View File
@@ -0,0 +1,113 @@
<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";
const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const;
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
let {
action,
submitLabel,
error,
onSuccess,
defaultStatus = "New",
defaultType = "Purchase",
}: {
action: string;
submitLabel: string;
error?: string | null;
onSuccess?: () => void;
defaultStatus?: string;
defaultType?: string;
} = $props();
let imageInputEl = $state<HTMLInputElement | null>(null);
let imageFileName = $state<string | null>(null);
function openScanReceipt() {
imageInputEl?.click();
}
function onImageChange(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
imageFileName = file ? file.name : null;
}
</script>
<form
method="POST"
action={action}
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === "success" && result.data?.success) {
onSuccess?.();
}
}}
class="flex flex-col gap-4"
>
<div class="space-y-2">
<Label for="Status">Status</Label>
<select
id="Status"
name="Status"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
{#each STATUS_OPTIONS as o}
<option value={o} selected={defaultStatus === o}>{o}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="Type">Type</Label>
<select
id="Type"
name="Type"
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
>
{#each TYPE_OPTIONS as o}
<option value={o} selected={defaultType === o}>{o}</option>
{/each}
</select>
</div>
<div class="space-y-2">
<Label for="Image">Image</Label>
<div class="flex flex-col gap-2">
<Button
type="button"
variant="outline"
class="w-full"
onclick={openScanReceipt}
>
Scan receipt
</Button>
<input
bind:this={imageInputEl}
id="Image"
name="Image"
type="file"
accept="image/*"
capture="environment"
onchange={onImageChange}
class="hidden"
/>
{#if imageFileName}
<p class="text-muted-foreground text-xs">Selected: {imageFileName}</p>
{:else}
<p class="text-muted-foreground text-xs">Optional. Use “Scan receipt” to take a photo or pick an image.</p>
{/if}
</div>
</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>
+23
View File
@@ -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<StackqReceipt>(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;
}
};
+95 -10
View File
@@ -1,12 +1,34 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
import type { StackqReceipt } from "$lib/types/receipts";
let { data, params }: { data: unknown; params: { id: string } } = $props();
const COLLECTION = "Stackq_Receipts";
let { data }: { data: { receipt: StackqReceipt } } = $props();
const receipt = $derived(data?.receipt);
function imageUrl(r: StackqReceipt): string | null {
if (!r?.Image) return null;
return `${POCKETBASE_BASE_URL}/api/files/${COLLECTION}/${r.id}/${r.Image}`;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return iso;
}
}
</script>
<svelte:head>
<title>Receipt Stackq</title>
<title>Receipt {receipt?.Status ?? receipt?.id ?? "—"} Stackq</title>
</svelte:head>
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
@@ -15,12 +37,75 @@
<h1 class="text-xl font-semibold sm:text-2xl">Receipt</h1>
</div>
<Card>
<CardContent class="flex flex-col gap-4 py-8">
<p class="text-muted-foreground text-sm">Receipt {params.id}</p>
<a href="/receipts">
<Button variant="outline">Back to receipts</Button>
</a>
</CardContent>
</Card>
{#if receipt}
<Card>
<CardContent class="flex flex-col gap-4 py-6">
<dl class="grid gap-3 text-sm">
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Status</dt>
<dd>{receipt.Status ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Type</dt>
<dd>{receipt.Type ?? "—"}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">User</dt>
<dd>
{#if receipt.expand?.User}
{receipt.expand.User.name ?? receipt.expand.User.email ?? receipt.expand.User.id}
{:else}
{/if}
</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Created</dt>
<dd>{formatDate(receipt.created)}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Updated</dt>
<dd>{formatDate(receipt.updated)}</dd>
</div>
{#if receipt.Data && Object.keys(receipt.Data).length > 0}
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Data</dt>
<dd class="min-w-0 overflow-auto rounded border bg-muted/30 p-2 font-mono text-xs">
<pre>{JSON.stringify(receipt.Data, null, 2)}</pre>
</dd>
</div>
{/if}
{#if receipt.Image}
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Image</dt>
<dd>
{@const url = imageUrl(receipt)}
{#if url}
<img
src={url}
alt="Receipt"
class="max-h-48 rounded border object-contain"
/>
{:else}
{/if}
</dd>
</div>
{/if}
</dl>
<a href="/receipts">
<Button variant="outline">Back to receipts</Button>
</a>
</CardContent>
</Card>
{:else}
<Card>
<CardContent class="py-8">
<p class="text-muted-foreground text-center text-sm">Receipt not found.</p>
<a href="/receipts" class="mt-4 flex justify-center">
<Button variant="outline">Back to receipts</Button>
</a>
</CardContent>
</Card>
{/if}
</main>