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.
This commit is contained in:
@@ -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))]"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user