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
+1
View File
@@ -5,6 +5,7 @@
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='4' fill='%231e293b'/><text x='16' y='22' font-size='18' font-family='system-ui' fill='%2394a3b8' text-anchor='middle'>S</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Stackq" />
@@ -0,0 +1,21 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.ContentProps & HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();
</script>
<CollapsiblePrimitive.Content
bind:ref
class={cn("overflow-hidden", className)}
{...restProps}
>
{@render children?.()}
</CollapsiblePrimitive.Content>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: CollapsiblePrimitive.TriggerProps & HTMLButtonAttributes<HTMLButtonElement> & { children?: Snippet } = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref class={cn("flex items-center gap-2", className)} {...restProps}>
{@render children?.()}
</CollapsiblePrimitive.Trigger>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import { cn } from "$lib/utils.js";
let {
open = $bindable(false),
class: className,
children,
...restProps
}: CollapsiblePrimitive.RootProps & HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();
</script>
<CollapsiblePrimitive.Root bind:open class={cn(className)} {...restProps}>
{@render children?.()}
</CollapsiblePrimitive.Root>
@@ -0,0 +1,5 @@
import Root from "./collapsible.svelte";
import Content from "./collapsible-content.svelte";
import Trigger from "./collapsible-trigger.svelte";
export { Root, Content, Trigger, Root as Collapsible, Content as CollapsibleContent, Trigger as CollapsibleTrigger };
+23
View File
@@ -0,0 +1,23 @@
/**
* Convert HEIC/HEIF blob to JPEG. Uses heic2any only when this function is called
* (dynamic import), so the receipt page can load even if heic2any fails to resolve.
* Suppresses the library's "Could not parse HEIF file" console errors during conversion.
*/
export async function convertHeicBlobToJpeg(blob: Blob): Promise<Blob> {
const originalError = console.error;
console.error = (...args: unknown[]) => {
const msg = args[0]?.toString?.() ?? "";
if (msg.includes("HEIF") || msg.includes("parse HEIF")) return;
originalError.apply(console, args);
};
try {
const mod = await import(/* @vite-ignore */ "heic2any");
const heic2any = (mod as unknown as {
default: (opts: { blob: Blob; toType: string; quality?: number }) => Promise<Blob | Blob[]>;
}).default;
const out = await heic2any({ blob, toType: "image/jpeg", quality: 0.92 });
return Array.isArray(out) ? out[0] : out;
} finally {
console.error = originalError;
}
}
+17 -1
View File
@@ -1,3 +1,19 @@
/** Line item stored in receipt Data.lineItems */
export interface ReceiptLineItem {
item: string;
description: string;
quantity: number;
price: number;
}
/** JSON shape stored in Stackq_Receipts.Data */
export interface ReceiptDataShape {
lineItems?: ReceiptLineItem[];
tax?: number;
/** Total printed on receipt (for validation) */
receiptTotal?: number;
}
/** PocketBase Stackq_Receipts collection record (and expanded relation). */
export interface StackqReceipt {
id: string;
@@ -5,7 +21,7 @@ export interface StackqReceipt {
Type: string;
User: string;
Image?: string;
Data?: Record<string, unknown>;
Data?: ReceiptDataShape | Record<string, unknown>;
created: string;
updated: string;
expand?: {
+3 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { enhance } from '$app/forms';
import { invalidate } from '$app/navigation';
import { browser } from '$app/environment';
@@ -11,6 +12,7 @@
let { data, children }: { data: { user: { id: string; email?: string; name?: string; darkmode: boolean } | null }; children?: import('svelte').Snippet } = $props();
const isReceiptDetailFull = $derived(!!$page?.url?.pathname?.match(/^\/receipts\/[^/]+$/));
let darkmode = $state(false);
$effect(() => {
@@ -38,7 +40,7 @@
}
</script>
<div class="app-shell">
<div class="app-shell" class:receipt-detail-full={isReceiptDetailFull}>
{#if data.user}
<header
class="fixed right-0 top-0 z-50 flex items-center gap-2 p-2 pr-[max(0.5rem,env(safe-area-inset-right))] pt-[max(0.5rem,env(safe-area-inset-top))]"
+61
View File
@@ -0,0 +1,61 @@
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
import type { RequestHandler } from "./$types";
function isHeicPath(path: string): boolean {
const lower = path.toLowerCase();
return lower.endsWith(".heic") || lower.endsWith(".heif");
}
export const GET: RequestHandler = async ({ params, request }) => {
const path = params.path;
if (!path) return new Response(null, { status: 404 });
const url = `${POCKETBASE_BASE_URL}/api/files/${path}`;
const cookie = request.headers.get("cookie") ?? "";
const res = await fetch(url, {
headers: { cookie },
});
if (!res.ok) {
return new Response(null, { status: res.status });
}
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
const body = res.body;
if (!body) return new Response(null, { status: 502 });
if (isHeicPath(path)) {
try {
const arrayBuffer = await res.arrayBuffer();
const inputBuffer = Buffer.from(arrayBuffer);
const convert = (await import("heic-convert")).default as (opts: {
buffer: Buffer;
format: string;
quality?: number;
}) => Promise<Buffer>;
const outputBuffer = await convert({
buffer: inputBuffer,
format: "JPEG",
quality: 0.92,
});
return new Response(outputBuffer, {
status: 200,
headers: {
"content-type": "image/jpeg",
"cache-control": res.headers.get("cache-control") ?? "private, max-age=3600",
},
});
} catch {
return new Response(null, { status: 500 });
}
}
return new Response(body, {
status: 200,
headers: {
"content-type": contentType,
"cache-control": res.headers.get("cache-control") ?? "private, max-age=3600",
},
});
};
+7
View File
@@ -153,6 +153,13 @@
max-width: 56rem; /* 896px comfortable items list on desktop */
}
}
/* Receipt detail page uses full width on desktop */
@media (min-width: 768px) {
.app-shell.receipt-detail-full {
max-width: none;
width: 100%;
}
}
/* Page content area fills shell and avoids outer scroll when possible */
.app-shell .page-view {
flex: 1 1 0;
+22 -6
View File
@@ -1,4 +1,5 @@
<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 { Input } from "$lib/components/ui/input";
@@ -16,19 +17,28 @@
(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return receipts;
const userName = (r: StackqReceipt) =>
(r.expand?.User?.name ?? r.expand?.User?.email ?? "").toLowerCase();
return receipts.filter(
(r) =>
r.id.toLowerCase().includes(q) ||
(r.Status ?? "").toLowerCase().includes(q) ||
(r.Type ?? "").toLowerCase().includes(q)
(r.Type ?? "").toLowerCase().includes(q) ||
userName(r).includes(q)
);
})()
);
function formatDate(iso: string): string {
function userName(receipt: StackqReceipt): string {
const u = receipt.expand?.User;
return u?.name ?? u?.email ?? "—";
}
/** Format ISO (UTC) date string as local date/time */
function formatDateLocal(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString(undefined, {
return d.toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
@@ -90,10 +100,16 @@
{#each filteredReceipts as receipt (receipt.id)}
<Card>
<CardContent class="p-4">
<a href="/receipts/{receipt.id}" class="font-medium hover:underline">
{receipt.Status ?? "—"} · {receipt.Type ?? "—"} · {formatDate(receipt.created)}
<a href="/receipts/{receipt.id}" class="block font-medium hover:underline">
{userName(receipt)}
</a>
<p class="text-muted-foreground mt-1 text-xs">ID: {receipt.id}</p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<Badge variant="secondary">{receipt.Type ?? "—"}</Badge>
<Badge variant="outline">{receipt.Status ?? "—"}</Badge>
<span class="text-muted-foreground text-xs">
{formatDateLocal(receipt.created)}
</span>
</div>
</CardContent>
</Card>
{/each}
+1
View File
@@ -40,6 +40,7 @@
<form
method="POST"
action={action}
enctype="multipart/form-data"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === "success" && result.data?.success) {
+91 -2
View File
@@ -1,9 +1,19 @@
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { error, fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqReceipt } from "$lib/types/receipts";
import type { ReceiptDataShape } 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;
function parseNum(val: unknown): number {
if (val === "" || val === null || val === undefined) return 0;
const n = Number(val);
return Number.isFinite(n) ? n : 0;
}
export const load: PageServerLoad = async ({ params, locals }) => {
const { id } = params;
try {
@@ -21,3 +31,82 @@ export const load: PageServerLoad = async ({ params, locals }) => {
throw err;
}
};
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 Status = (form.get("Status") as string)?.trim() || "New";
const Type = (form.get("Type") as string)?.trim() || "Purchase";
const dataJson = (form.get("dataJson") as string)?.trim() ?? "{}";
const imageFile = form.get("Image") as File | null;
const hasImage = imageFile && imageFile.size > 0;
let Data: ReceiptDataShape = {};
try {
const parsed = JSON.parse(dataJson) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const o = parsed as Record<string, unknown>;
if (Array.isArray(o.lineItems)) {
Data.lineItems = o.lineItems.filter(
(row: unknown): row is { item: string; description: string; quantity: number; price: number } =>
row != null &&
typeof row === "object" &&
"item" in row &&
"quantity" in row &&
"price" in row
).map((row) => ({
item: String(row.item ?? ""),
description: String(row.description ?? ""),
quantity: parseNum(row.quantity),
price: parseNum(row.price),
}));
}
if (typeof o.tax === "number" && Number.isFinite(o.tax)) Data.tax = o.tax;
else if (typeof o.tax === "string") Data.tax = parseNum(o.tax);
if (typeof o.receiptTotal === "number" && Number.isFinite(o.receiptTotal)) {
Data.receiptTotal = o.receiptTotal;
} else if (typeof o.receiptTotal === "string") {
Data.receiptTotal = parseNum(o.receiptTotal);
}
}
} catch {
return fail(400, { error: "Invalid receipt data JSON", form: "update" });
}
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",
Data: Object.keys(Data).length ? Data : undefined,
};
if (hasImage) body.Image = imageFile;
try {
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, "/receipts");
},
};
+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}