469 lines
18 KiB
Svelte
469 lines
18 KiB
Svelte
<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>
|