Files
Stackq/src/routes/receipts/[id]/+page.svelte
T

469 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { convertImageToWebP } from "$lib/convert-image-to-webp";
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 Loader2Icon from "@lucide/svelte/icons/loader-2";
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, 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 {
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, {
dateStyle: "medium",
timeStyle: "short",
});
} catch {
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);
let imageConverting = $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;
});
async function onImageChange() {
const input = imageInputEl;
const file = input?.files?.[0] ?? null;
if (!file || !file.type.startsWith("image/")) {
selectedImageFile = null;
imageFileName = null;
return;
}
imageConverting = true;
selectedImageFile = null;
imageFileName = null;
await new Promise((r) => setTimeout(r, 0));
try {
const webpFile = await convertImageToWebP(file);
selectedImageFile = webpFile;
imageFileName = webpFile.name;
} catch {
selectedImageFile = null;
imageFileName = null;
if (input) input.value = "";
} finally {
imageConverting = false;
}
}
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="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}
{@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}
<span class="sr-only">{imageOpen ? "Hide image" : "Show image"}</span>
</Collapsible.Trigger>
</div>
<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="relative flex items-center gap-2">
<input
id="receipt-image-input"
bind:this={imageInputEl}
type="file"
name="Image"
accept="image/*"
capture="environment"
onchange={onImageChange}
class="absolute opacity-0 w-0 h-0 overflow-hidden"
tabindex="-1"
aria-label={receipt.Image ? "Replace image" : "Add image"}
/>
{#if imageConverting}
<div
class="border-input bg-background inline-flex h-8 cursor-not-allowed items-center justify-center gap-2 rounded-md border px-3 text-sm font-medium opacity-80"
aria-busy="true"
aria-live="polite"
>
<Loader2Icon class="size-4 shrink-0 animate-spin" aria-hidden />
<span>Processing image…</span>
</div>
{:else}
<label
for="receipt-image-input"
class="border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-8 cursor-pointer items-center justify-center rounded-md border px-3 text-sm font-medium shadow-xs transition-colors"
>
{receipt.Image ? "Replace image" : "Add image"}
</label>
{/if}
{#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}
<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>