Add receipt status management functionality, allowing users to update receipt statuses through a new server action. Introduce STATUS_OPTIONS for consistent status handling across components. Enhance UI to support status selection and display, improving overall user experience in receipt management.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m18s

This commit is contained in:
eewing
2026-02-26 14:26:15 -06:00
parent e3d96b8818
commit c89329dada
7 changed files with 174 additions and 23 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ export const options = {
app: ({ head, body, assets, nonce, env }) => "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <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>\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, viewport-fit=cover\" />\n <meta name=\"theme-color\" content=\"#0f172a\" />\n <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n <meta name=\"apple-mobile-web-app-title\" content=\"Stackq\" />\n <link rel=\"apple-touch-icon\" href=\"/icon.svg\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n",
error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
},
version_hash: "1gfvo51"
version_hash: "49o61d"
};
export async function get_hooks() {
@@ -2,6 +2,7 @@
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqReceipt } from "$lib/types/receipts";
import { STATUS_OPTIONS } from "$lib/types/receipts";
import { convertHeicFileToJpegIfNeeded } from "$lib/convert-heic-server";
import { validateImageUpload } from "$lib/validate-image-upload";
@@ -80,5 +81,30 @@ export const actions = {
return { success: true };
},
setStatus: async ({ request, locals }: import('./$types').RequestEvent) => {
const user = locals.user as { id: string; Admin?: boolean } | null;
if (!user?.id) return fail(401, { error: "Not logged in", setStatus: null });
const form = await request.formData();
const id = (form.get("id") as string)?.trim();
const Status = (form.get("Status") as string)?.trim() || "New";
if (!id) return fail(400, { error: "Missing receipt id", setStatus: null });
if (!STATUS_OPTIONS.includes(Status as (typeof STATUS_OPTIONS)[number])) {
return fail(400, { error: "Invalid status", setStatus: null });
}
const isAdmin = user.Admin === true;
try {
const existing = await locals.pb.collection(COLLECTION).getOne<StackqReceipt>(id);
if (!isAdmin && existing.User !== user.id) {
return fail(404, { error: "Receipt not found", setStatus: null });
}
await locals.pb.collection(COLLECTION).update(id, { Status });
} catch (e: unknown) {
const is404 = e && typeof e === "object" && "status" in e && (e as { status: number }).status === 404;
if (is404) return fail(404, { error: "Receipt not found", setStatus: null });
return fail(500, { error: "Failed to update status", setStatus: null });
}
return { success: true, setStatus: id };
},
};
;null as any as Actions;
+4
View File
@@ -1,3 +1,7 @@
/** Receipt status values for Stackq_Receipts */
export const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const;
export type ReceiptStatus = (typeof STATUS_OPTIONS)[number];
/** Line item stored in receipt Data.lineItems */
export interface ReceiptLineItem {
item: string;
+26
View File
@@ -1,6 +1,7 @@
import { fail } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import type { StackqReceipt } from "$lib/types/receipts";
import { STATUS_OPTIONS } from "$lib/types/receipts";
import { convertHeicFileToJpegIfNeeded } from "$lib/convert-heic-server";
import { validateImageUpload } from "$lib/validate-image-upload";
@@ -79,4 +80,29 @@ export const actions: Actions = {
return { success: true };
},
setStatus: async ({ request, locals }) => {
const user = locals.user as { id: string; Admin?: boolean } | null;
if (!user?.id) return fail(401, { error: "Not logged in", setStatus: null });
const form = await request.formData();
const id = (form.get("id") as string)?.trim();
const Status = (form.get("Status") as string)?.trim() || "New";
if (!id) return fail(400, { error: "Missing receipt id", setStatus: null });
if (!STATUS_OPTIONS.includes(Status as (typeof STATUS_OPTIONS)[number])) {
return fail(400, { error: "Invalid status", setStatus: null });
}
const isAdmin = user.Admin === true;
try {
const existing = await locals.pb.collection(COLLECTION).getOne<StackqReceipt>(id);
if (!isAdmin && existing.User !== user.id) {
return fail(404, { error: "Receipt not found", setStatus: null });
}
await locals.pb.collection(COLLECTION).update(id, { Status });
} catch (e: unknown) {
const is404 = e && typeof e === "object" && "status" in e && (e as { status: number }).status === 404;
if (is404) return fail(404, { error: "Receipt not found", setStatus: null });
return fail(500, { error: "Failed to update status", setStatus: null });
}
return { success: true, setStatus: id };
},
};
+110 -13
View File
@@ -3,14 +3,18 @@
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import * as Select from "$lib/components/ui/select";
import * as Dialog from "$lib/components/ui/dialog";
import ReceiptForm from "./ReceiptForm.svelte";
import type { StackqReceipt } from "$lib/types/receipts";
import type { ActionData } from "./$types";
import { STATUS_OPTIONS } from "$lib/types/receipts";
import { enhance } from "$app/forms";
let { data, form }: { data: { receipts: StackqReceipt[] }; form?: ActionData } = $props();
let searchQuery = $state("");
let createDialogOpen = $state(false);
let activeTab = $state<(typeof STATUS_OPTIONS)[number]>("New");
const receipts = $derived(data?.receipts ?? []);
const filteredReceipts = $derived(
@@ -24,11 +28,29 @@
r.id.toLowerCase().includes(q) ||
(r.Status ?? "").toLowerCase().includes(q) ||
(r.Type ?? "").toLowerCase().includes(q) ||
(r.Description ?? "").toLowerCase().includes(q) ||
userName(r).includes(q)
);
})()
);
const receiptsByStatus = $derived(
(() => {
const by: Record<string, StackqReceipt[]> = {};
for (const s of STATUS_OPTIONS) by[s] = [];
for (const r of filteredReceipts) {
const s = (r.Status ?? "New") as (typeof STATUS_OPTIONS)[number];
if (STATUS_OPTIONS.includes(s)) by[s].push(r);
else by["New"].push(r);
}
return by;
})()
);
const tabReceipts = $derived(receiptsByStatus[activeTab] ?? []);
const countNew = $derived(receiptsByStatus["New"]?.length ?? 0);
const countDataEntered = $derived(receiptsByStatus["Data Entered"]?.length ?? 0);
function userName(receipt: StackqReceipt): string {
const u = receipt.expand?.User;
return u?.name ?? u?.email ?? "—";
@@ -50,6 +72,15 @@
function onReceiptCreateSuccess() {
createDialogOpen = false;
}
function submitStatusForm(receiptId: string, newStatus: string) {
const form = document.querySelector<HTMLFormElement>(`form[data-receipt-id="${receiptId}"]`);
if (form) {
const statusInput = form.querySelector<HTMLInputElement>('input[name="Status"]');
if (statusInput) statusInput.value = newStatus;
form.requestSubmit();
}
}
</script>
<svelte:head>
@@ -96,25 +127,91 @@
</CardContent>
</Card>
{:else}
<div class="flex flex-col gap-2">
{#each filteredReceipts as receipt (receipt.id)}
<div class="flex flex-col gap-4">
<div
class="border-border flex gap-1 rounded-lg border p-1"
role="tablist"
aria-label="Receipt status"
>
{#each STATUS_OPTIONS as status}
{@const count = status === "New" ? countNew : status === "Data Entered" ? countDataEntered : null}
<button
type="button"
role="tab"
aria-selected={activeTab === status}
class="focus:ring-ring rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 {activeTab === status
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted hover:text-muted-foreground'}"
onclick={() => (activeTab = status)}
>
{status}
{#if count !== null}
<span class="ml-1.5 rounded-full bg-black/15 px-1.5 py-0.5 text-xs {activeTab === status ? 'bg-white/25' : ''}">{count}</span>
{/if}
</button>
{/each}
</div>
{#if tabReceipts.length === 0}
<Card>
<CardContent class="p-4">
<a href="/receipts/{receipt.id}" class="block font-medium hover:underline">
{userName(receipt)}
</a>
<div class="mt-2 flex flex-wrap items-center gap-2">
<CardContent class="py-8 text-center text-sm text-muted-foreground">
No receipts with status “{activeTab}”.
</CardContent>
</Card>
{:else}
<div class="flex flex-col gap-2">
{#each tabReceipts as receipt (receipt.id)}
<a
href="/receipts/{receipt.id}"
class="block focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-lg"
>
<Card class="hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent class="p-4 flex flex-col gap-2">
<div class="flex items-start justify-between gap-2">
<span class="font-medium">{userName(receipt)}</span>
<form
data-receipt-id={receipt.id}
method="POST"
action="?/setStatus"
use:enhance
class="shrink-0 inline"
>
<input type="hidden" name="id" value={receipt.id} />
<input type="hidden" name="Status" value={receipt.Status ?? "New"} />
<Select.Root
type="single"
value={receipt.Status ?? "New"}
onValueChange={(v) => v && submitStatusForm(receipt.id, v)}
>
<Select.Trigger
class="h-8 min-w-[8rem] border-0 bg-transparent shadow-none hover:bg-muted/80"
onclick={(e) => e.stopPropagation()}
>
<span>{receipt.Status ?? "New"}</span>
</Select.Trigger>
<Select.Content>
{#each STATUS_OPTIONS as o}
<Select.Item value={o} label={o} />
{/each}
</Select.Content>
</Select.Root>
</form>
</div>
<div class="flex flex-wrap items-center gap-2 text-muted-foreground">
<Badge variant="secondary">{receipt.Type ?? "—"}</Badge>
<Badge variant="outline">{receipt.Status ?? "—"}</Badge>
<span class="text-muted-foreground text-xs">
{formatDateLocal(receipt.created)}
</span>
<span class="text-xs">{formatDateLocal(receipt.created)}</span>
{#if receipt.Description}
<span class="text-xs truncate max-w-[200px]" title={receipt.Description}>{receipt.Description}</span>
{/if}
</div>
</CardContent>
</Card>
</a>
{/each}
</div>
{/if}
</div>
{/if}
</main>
<Dialog.Root bind:open={createDialogOpen}>
@@ -127,8 +224,8 @@
action="/receipts?/create"
submitLabel="Create"
error={form?.error}
defaultType={form?.Type ?? "Purchase"}
defaultDescription={form?.Description ?? ""}
defaultType={(form as { Type?: string })?.Type ?? "Purchase"}
defaultDescription={(form as { Description?: string })?.Description ?? ""}
onSuccess={onReceiptCreateSuccess}
/>
</Dialog.Content>
+2 -3
View File
@@ -1,14 +1,13 @@
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";
import type { StackqReceipt, ReceiptDataShape } from "$lib/types/receipts";
import { STATUS_OPTIONS } from "$lib/types/receipts";
import { convertHeicFileToJpegIfNeeded } from "$lib/convert-heic-server";
import { parseNumWithDefault } from "$lib/parse-form";
import { validateImageUpload } from "$lib/validate-image-upload";
const COLLECTION = "Stackq_Receipts";
const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const;
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
const MAX_LINE_ITEMS = 500;
+2 -3
View File
@@ -13,11 +13,10 @@
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";
import type { StackqReceipt, ReceiptLineItem } from "$lib/types/receipts";
import { STATUS_OPTIONS } 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();