Enhance receipt management by adding support for image uploads and improving data handling in receipt forms. Update route parameters and type definitions for better data structure. Refactor UI components for improved user experience and responsiveness, including collapsible sections for receipt images and dynamic line item management.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m42s

This commit is contained in:
eewing
2026-02-19 10:57:15 -06:00
parent 115eecc6f3
commit f51f9c9f81
49 changed files with 879 additions and 11651 deletions
+381 -59
View File
@@ -1,20 +1,88 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import * as Collapsible from "$lib/components/ui/collapsible";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
import { enhance } from "$app/forms";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import type { StackqReceipt } from "$lib/types/receipts";
import type { ReceiptLineItem } from "$lib/types/receipts";
const COLLECTION = "Stackq_Receipts";
const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const;
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
let { data }: { data: { receipt: StackqReceipt } } = $props();
let { data, form }: { data: { receipt: StackqReceipt }; form?: { error?: string; form?: string } } = $props();
const receipt = $derived(data?.receipt);
const formData = $state<{ lineItems: ReceiptLineItem[]; tax: number; receiptTotal: number }>({
lineItems: [{ item: "", description: "", quantity: 1, price: 0 }],
tax: 0,
receiptTotal: 0,
});
$effect(() => {
const r = receipt;
if (!r) return;
const data = r.Data;
const nextItems: ReceiptLineItem[] =
data && typeof data === "object" && "lineItems" in data
? (() => {
const arr = (data as { lineItems?: ReceiptLineItem[] }).lineItems;
if (Array.isArray(arr) && arr.length > 0) {
return arr.map((row) => ({
item: String(row?.item ?? ""),
description: String(row?.description ?? ""),
quantity: Number(row?.quantity) || 0,
price: Number(row?.price) || 0,
}));
}
return [{ item: "", description: "", quantity: 1, price: 0 }];
})()
: [{ item: "", description: "", quantity: 1, price: 0 }];
const nextTax =
data && typeof data === "object" && "tax" in data
? (() => {
const t = (data as { tax?: number }).tax;
return typeof t === "number" && Number.isFinite(t) ? t : 0;
})()
: 0;
const nextReceiptTotal =
data && typeof data === "object" && "receiptTotal" in data
? (() => {
const t = (data as { receiptTotal?: number }).receiptTotal;
return typeof t === "number" && Number.isFinite(t) ? t : 0;
})()
: 0;
formData.lineItems = nextItems;
formData.tax = nextTax;
formData.receiptTotal = nextReceiptTotal;
});
const lineItems = $derived(formData.lineItems);
const tax = $derived(formData.tax);
const receiptTotal = $derived(formData.receiptTotal);
const subtotal = $derived(
lineItems.reduce((sum, row) => sum + (Number(row.quantity) || 0) * (Number(row.price) || 0), 0)
);
const total = $derived(subtotal + (Number(tax) || 0));
const totalDelta = $derived((Number(receiptTotal) || 0) - (Number(total) || 0));
function imageUrl(r: StackqReceipt): string | null {
if (!r?.Image) return null;
return `${POCKETBASE_BASE_URL}/api/files/${COLLECTION}/${r.id}/${r.Image}`;
const raw = r?.Image;
if (raw == null || raw === "") return null;
const filename = typeof raw === "string" ? raw : Array.isArray(raw) ? raw[0] : null;
if (!filename) return null;
return `/api/files/${COLLECTION}/${r!.id}/${encodeURIComponent(filename)}`;
}
const currentImageUrl = $derived(receipt?.Image ? imageUrl(receipt) : null);
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
@@ -25,77 +93,331 @@
return iso;
}
}
function addLine() {
formData.lineItems.push({ item: "", description: "", quantity: 1, price: 0 });
}
function removeLine(i: number) {
formData.lineItems.splice(i, 1);
if (formData.lineItems.length === 0) formData.lineItems.push({ item: "", description: "", quantity: 1, price: 0 });
}
function buildDataJson(): string {
const data = {
lineItems: formData.lineItems.map((row) => ({
item: String(row.item ?? ""),
description: String(row.description ?? ""),
quantity: Number(row.quantity) || 0,
price: Number(row.price) || 0,
})),
tax: Number(formData.tax) || 0,
receiptTotal: Number(formData.receiptTotal) || 0,
};
return JSON.stringify(data);
}
let imageInputEl = $state<HTMLInputElement | null>(null);
let imageFileName = $state<string | null>(null);
let selectedImageFile = $state<File | null>(null);
let imageError = $state(false);
/** On small screens image is collapsed by default; on lg+ expanded. */
let imageOpen = $state(false);
let imageOpenInitialized = $state(false);
$effect(() => {
if (typeof window === "undefined" || imageOpenInitialized) return;
imageOpenInitialized = true;
const mq = window.matchMedia("(min-width: 1024px)");
imageOpen = mq.matches;
});
function openReplaceImage() {
imageInputEl?.click();
}
function onImageChange() {
const input = imageInputEl;
const file = input?.files?.[0] ?? null;
selectedImageFile = file;
imageFileName = file ? file.name : null;
}
function imageLoadError() {
imageError = true;
}
$effect(() => {
if (receipt?.Image) imageError = false;
});
</script>
<svelte:head>
<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">
<main class="flex w-full max-w-full flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
<div class="flex items-center gap-3">
<a href="/receipts" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to receipts">Back</a>
<h1 class="text-xl font-semibold sm:text-2xl">Receipt</h1>
</div>
{#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}
{@const url = imageUrl(receipt)}
<div class="flex gap-2">
<dt class="text-muted-foreground w-24 shrink-0">Image</dt>
<dd>
{#if url}
<img
src={url}
alt="Receipt"
class="max-h-48 rounded border object-contain"
/>
{@const url = currentImageUrl}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_1.2fr]">
<Card class="h-fit w-full">
<CardContent class="p-4">
<Collapsible.Root bind:open={imageOpen} class="space-y-2">
<div class="flex items-center justify-between gap-2 lg:hidden">
<span class="text-muted-foreground text-sm font-medium">Receipt image</span>
<Collapsible.Trigger
class="inline-flex size-8 items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground"
type="button"
>
{#if imageOpen}
<ChevronUpIcon class="size-4" aria-hidden />
{:else}
<ChevronDownIcon class="size-4" aria-hidden />
{/if}
</dd>
<span class="sr-only">{imageOpen ? "Hide image" : "Show image"}</span>
</Collapsible.Trigger>
</div>
{/if}
</dl>
<a href="/receipts">
<Button variant="outline">Back to receipts</Button>
</a>
<Collapsible.Content>
{#if url}
<div class="min-h-[200px] w-full rounded border bg-muted/20">
{#if imageError}
<div class="flex min-h-[200px] flex-col items-center justify-center gap-2 p-4 text-center text-sm">
<p class="text-muted-foreground">Image could not be loaded.</p>
<a href={url} download class="text-primary hover:underline text-sm">Download image</a>
</div>
{:else}
<img
src={url}
alt="Receipt"
class="h-auto w-full rounded object-contain"
loading="lazy"
onerror={imageLoadError}
/>
{/if}
</div>
{:else}
<p class="text-muted-foreground text-center text-sm">No image. Add one in the form and save.</p>
{/if}
</Collapsible.Content>
</Collapsible.Root>
</CardContent>
</Card>
<Card>
<CardContent class="p-4">
<form
method="POST"
action="?/update"
enctype="multipart/form-data"
use:enhance={({ formData: fd }) => {
if (selectedImageFile) fd.set("Image", selectedImageFile);
return async ({ update, result }) => {
await update();
if (result.type === "success" && (result.data as { success?: boolean } | undefined)?.success) {
selectedImageFile = null;
imageFileName = null;
}
};
}}
class="flex flex-col gap-4"
>
<input type="hidden" name="dataJson" value={buildDataJson()} />
<div class="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onclick={openReplaceImage}>
{receipt.Image ? "Replace image" : "Add image"}
</Button>
<input
bind:this={imageInputEl}
type="file"
name="Image"
accept="image/*"
capture="environment"
onchange={onImageChange}
class="hidden"
/>
{#if imageFileName}
<span class="text-muted-foreground text-xs">New: {imageFileName}</span>
{/if}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<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={receipt.Status === 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={receipt.Type === o}>{o}</option>
{/each}
</select>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Line items</Label>
<Button type="button" variant="outline" size="sm" onclick={addLine}>Add line</Button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="pb-2 pr-2 font-medium">Item</th>
<th class="pb-2 pr-2 font-medium">Description</th>
<th class="w-20 pb-2 pr-2 font-medium">Qty</th>
<th class="w-24 pb-2 pr-2 font-medium">Price</th>
<th class="w-9 pb-2"></th>
</tr>
</thead>
<tbody>
{#each formData.lineItems as row, i}
<tr class="border-b">
<td class="py-1.5 pr-2">
<Input
type="text"
placeholder="Item"
class="h-8 text-sm"
bind:value={formData.lineItems[i].item}
/>
</td>
<td class="py-1.5 pr-2">
<Input
type="text"
placeholder="Description"
class="h-8 text-sm"
bind:value={formData.lineItems[i].description}
/>
</td>
<td class="py-1.5 pr-2">
<Input
type="number"
step="1"
class="h-8 text-sm"
bind:value={formData.lineItems[i].quantity}
/>
</td>
<td class="py-1.5 pr-2">
<Input
type="number"
step="0.01"
class="h-8 text-sm"
bind:value={formData.lineItems[i].price}
/>
</td>
<td class="py-1.5">
<Button
type="button"
variant="ghost"
size="sm"
class="text-destructive h-8 w-8 p-0"
onclick={() => removeLine(i)}
title="Remove line"
>
×
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<div class="border-t pt-4">
<div class="flex items-center gap-4">
<div class="space-y-2">
<Label for="tax">Tax</Label>
<Input
id="tax"
type="number"
step="0.01"
class="w-28"
bind:value={formData.tax}
/>
</div>
<div class="space-y-2">
<Label for="receiptTotal">Receipt total</Label>
<Input
id="receiptTotal"
type="number"
step="0.01"
class="w-28"
bind:value={formData.receiptTotal}
/>
</div>
</div>
<dl class="mt-4 space-y-1 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-muted-foreground">Subtotal</dt>
<dd>${subtotal.toFixed(2)}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-muted-foreground">Tax</dt>
<dd>${(Number(tax) || 0).toFixed(2)}</dd>
</div>
<div class="flex justify-between gap-4 border-t pt-2 font-medium">
<dt>Total</dt>
<dd>${total.toFixed(2)}</dd>
</div>
<div class="flex justify-between gap-4 pt-2">
<dt class="text-muted-foreground">Receipt total</dt>
<dd>${(Number(receiptTotal) || 0).toFixed(2)}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-muted-foreground">Diff</dt>
<dd class={Math.abs(totalDelta) < 0.01 ? "text-emerald-600" : "text-amber-600"}>
{totalDelta >= 0 ? "+" : ""}{totalDelta.toFixed(2)}
</dd>
</div>
</dl>
</div>
{#if form?.error && form?.form === "update"}
<p class="text-destructive text-sm">{form.error}</p>
{/if}
<div class="flex flex-wrap gap-2 pt-2">
<Button type="submit">Save</Button>
<a href="/receipts">
<Button type="button" variant="outline">Back to receipts</Button>
</a>
</div>
</form>
</CardContent>
</Card>
</div>
<Card>
<CardContent class="flex flex-row flex-wrap items-center justify-between gap-4 py-4">
<p class="text-muted-foreground text-sm">
User: {receipt.expand?.User?.name ?? receipt.expand?.User?.email ?? "—"} · Created {formatDate(receipt.created)} · Updated {formatDate(receipt.updated)}
</p>
<form
method="POST"
action="?/delete"
onsubmit={(e) => {
if (!confirm("Delete this receipt? This cannot be undone.")) {
e.preventDefault();
}
}}
>
<Button type="submit" variant="destructive">Delete receipt</Button>
</form>
</CardContent>
</Card>
{:else}