Implement receipt management features including creation, loading, and display of receipts. Refactor related components and enhance UI with dialogs for adding new receipts. Update type definitions and improve error handling for better user experience.
This commit is contained in:
@@ -32,7 +32,7 @@ export const dictionary = {
|
|||||||
"/logout": [~6],
|
"/logout": [~6],
|
||||||
"/receipts": [~3],
|
"/receipts": [~3],
|
||||||
"/receipts/new": [4],
|
"/receipts/new": [4],
|
||||||
"/receipts/[id]": [5],
|
"/receipts/[id]": [~5],
|
||||||
"/transactions": [~7],
|
"/transactions": [~7],
|
||||||
"/transactions/new": [8],
|
"/transactions/new": [8],
|
||||||
"/transactions/[id]": [9]
|
"/transactions/[id]": [9]
|
||||||
|
|||||||
@@ -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=\"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",
|
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=\"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"
|
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: "16sw9ck"
|
version_hash: "11efg8s"
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function get_hooks() {
|
export async function get_hooks() {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"src/routes/+layout.server.ts"
|
"src/routes/+layout.server.ts"
|
||||||
],
|
],
|
||||||
"/receipts/[id]": [
|
"/receipts/[id]": [
|
||||||
|
"src/routes/receipts/[id]/+page.server.ts",
|
||||||
"src/routes/+layout.ts",
|
"src/routes/+layout.ts",
|
||||||
"src/routes/+layout.server.ts"
|
"src/routes/+layout.server.ts"
|
||||||
],
|
],
|
||||||
|
|||||||
+7
-1
@@ -16,7 +16,13 @@ type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
|
|||||||
|
|
||||||
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
|
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
|
||||||
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
|
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
|
||||||
export type ActionData = unknown;
|
type ExcludeActionFailure<T> = T extends Kit.ActionFailure<any> ? never : T extends void ? never : T;
|
||||||
|
type ActionsSuccess<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: ExcludeActionFailure<Awaited<ReturnType<T[Key]>>>; }[keyof T];
|
||||||
|
type ExtractActionFailure<T> = T extends Kit.ActionFailure<infer X> ? X extends void ? never : X : never;
|
||||||
|
type ActionsFailure<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: Exclude<ExtractActionFailure<Awaited<ReturnType<T[Key]>>>, void>; }[keyof T];
|
||||||
|
type ActionsExport = typeof import('./proxy+page.server.js').actions
|
||||||
|
export type SubmitFunction = Kit.SubmitFunction<Expand<ActionsSuccess<ActionsExport>>, Expand<ActionsFailure<ActionsExport>>>
|
||||||
|
export type ActionData = Expand<Kit.AwaitedActions<ActionsExport>> | null;
|
||||||
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('./proxy+page.server.js').load>>>>>>;
|
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('./proxy+page.server.js').load>>>>>>;
|
||||||
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
|
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
|
||||||
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
|
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
|
||||||
|
|||||||
+10
-3
@@ -11,9 +11,16 @@ type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Pa
|
|||||||
type EnsureDefined<T> = T extends null | undefined ? {} : T;
|
type EnsureDefined<T> = T extends null | undefined ? {} : T;
|
||||||
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
|
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
|
||||||
export type Snapshot<T = any> = Kit.Snapshot<T>;
|
export type Snapshot<T = any> = Kit.Snapshot<T>;
|
||||||
|
type PageServerParentData = EnsureDefined<import('../../$types.js').LayoutServerData>;
|
||||||
type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>;
|
type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>;
|
||||||
|
|
||||||
export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
|
export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
|
||||||
export type PageServerData = null;
|
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
|
||||||
export type PageData = Expand<PageParentData>;
|
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
|
||||||
export type PageProps = { params: RouteParams; data: PageData }
|
export type ActionData = unknown;
|
||||||
|
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('./proxy+page.server.js').load>>>>>>;
|
||||||
|
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
|
||||||
|
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
|
||||||
|
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
|
||||||
|
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
|
||||||
|
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
|
||||||
@@ -1,8 +1,57 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import type { PageServerLoad } from "./$types";
|
import { fail } from "@sveltejs/kit";
|
||||||
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
|
import type { StackqReceipt } from "$lib/types/receipts";
|
||||||
|
|
||||||
export const load = async () => {
|
const COLLECTION = "Stackq_Receipts";
|
||||||
// Placeholder: no PocketBase receipts collection yet. Return empty list.
|
|
||||||
return { receipts: [] as { id: string }[] };
|
const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const;
|
||||||
|
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
|
||||||
|
|
||||||
|
export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
|
||||||
|
const list = await locals.pb
|
||||||
|
.collection(COLLECTION)
|
||||||
|
.getFullList<StackqReceipt>({
|
||||||
|
sort: "-created",
|
||||||
|
expand: "User",
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(COLLECTION, err);
|
||||||
|
return [] as StackqReceipt[];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { receipts: list };
|
||||||
};
|
};
|
||||||
;null as any as PageServerLoad;
|
|
||||||
|
export const actions = {
|
||||||
|
create: async ({ request, locals }: import('./$types').RequestEvent) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user?.id) return fail(401, { error: "You must be logged in to create a receipt." });
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const Status = (form.get("Status") as string)?.trim() || "New";
|
||||||
|
const Type = (form.get("Type") as string)?.trim() || "Purchase";
|
||||||
|
const imageFile = form.get("Image") as File | null;
|
||||||
|
const hasImage = imageFile && imageFile.size > 0;
|
||||||
|
|
||||||
|
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",
|
||||||
|
User: user.id,
|
||||||
|
};
|
||||||
|
if (hasImage) body.Image = imageFile;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await locals.pb.collection(COLLECTION).create(body);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
err && typeof err === "object" && "message" in err
|
||||||
|
? String((err as { message: string }).message)
|
||||||
|
: "Failed to create receipt.";
|
||||||
|
return fail(500, { error: message, Status, Type });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
;null as any as Actions;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** PocketBase Stackq_Receipts collection record (and expanded relation). */
|
||||||
|
export interface StackqReceipt {
|
||||||
|
id: string;
|
||||||
|
Status: string;
|
||||||
|
Type: string;
|
||||||
|
User: string;
|
||||||
|
Image?: string;
|
||||||
|
Data?: Record<string, unknown>;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
expand?: {
|
||||||
|
User?: { id: string; email?: string; name?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
import ItemForm from "./items/ItemForm.svelte";
|
import ItemForm from "./items/ItemForm.svelte";
|
||||||
|
import ReceiptForm from "./receipts/ReceiptForm.svelte";
|
||||||
import BarcodeScanDialog from "$lib/components/BarcodeScanDialog.svelte";
|
import BarcodeScanDialog from "$lib/components/BarcodeScanDialog.svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
let {
|
let {
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
|
let receiptDialogOpen = $state(false);
|
||||||
let scanDialogOpen = $state(false);
|
let scanDialogOpen = $state(false);
|
||||||
const items = $derived(data?.items ?? []);
|
const items = $derived(data?.items ?? []);
|
||||||
const itemsForParent = $derived(data?.itemsForParent ?? []);
|
const itemsForParent = $derived(data?.itemsForParent ?? []);
|
||||||
@@ -23,6 +25,11 @@
|
|||||||
goto("/items");
|
goto("/items");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onReceiptSuccess() {
|
||||||
|
receiptDialogOpen = false;
|
||||||
|
goto("/receipts");
|
||||||
|
}
|
||||||
|
|
||||||
function onScanResult(value: string) {
|
function onScanResult(value: string) {
|
||||||
const sku = value.trim();
|
const sku = value.trim();
|
||||||
const found = items.find((i) => (i.SKU ?? "").trim() === sku);
|
const found = items.find((i) => (i.SKU ?? "").trim() === sku);
|
||||||
@@ -39,9 +46,9 @@
|
|||||||
<a href="/transactions/new" class="block">
|
<a href="/transactions/new" class="block">
|
||||||
<Button class="h-16 min-h-16 w-full" variant="outline">New transaction</Button>
|
<Button class="h-16 min-h-16 w-full" variant="outline">New transaction</Button>
|
||||||
</a>
|
</a>
|
||||||
<a href="/receipts/new" class="block">
|
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (receiptDialogOpen = true)}>
|
||||||
<Button class="h-16 min-h-16 w-full" variant="outline">New receipt</Button>
|
New receipt
|
||||||
</a>
|
</Button>
|
||||||
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (dialogOpen = true)}>New Item</Button>
|
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (dialogOpen = true)}>New Item</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,4 +85,18 @@
|
|||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={receiptDialogOpen}>
|
||||||
|
<Dialog.Content class="max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] overflow-y-auto rounded-lg sm:w-full">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>New receipt</Dialog.Title>
|
||||||
|
<Dialog.Description>Add a new receipt. Optionally attach an image.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<ReceiptForm
|
||||||
|
action="/receipts?/create"
|
||||||
|
submitLabel="Create"
|
||||||
|
onSuccess={onReceiptSuccess}
|
||||||
|
/>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
<BarcodeScanDialog bind:open={scanDialogOpen} onResult={onScanResult} title="Scan to look up item" />
|
<BarcodeScanDialog bind:open={scanDialogOpen} onResult={onScanResult} title="Scan to look up item" />
|
||||||
|
|||||||
@@ -1,6 +1,55 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import { fail } from "@sveltejs/kit";
|
||||||
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
|
import type { StackqReceipt } from "$lib/types/receipts";
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
const COLLECTION = "Stackq_Receipts";
|
||||||
// Placeholder: no PocketBase receipts collection yet. Return empty list.
|
|
||||||
return { receipts: [] as { id: string }[] };
|
const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const;
|
||||||
|
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const list = await locals.pb
|
||||||
|
.collection(COLLECTION)
|
||||||
|
.getFullList<StackqReceipt>({
|
||||||
|
sort: "-created",
|
||||||
|
expand: "User",
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(COLLECTION, err);
|
||||||
|
return [] as StackqReceipt[];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { receipts: list };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
create: async ({ request, locals }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user?.id) return fail(401, { error: "You must be logged in to create a receipt." });
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const Status = (form.get("Status") as string)?.trim() || "New";
|
||||||
|
const Type = (form.get("Type") as string)?.trim() || "Purchase";
|
||||||
|
const imageFile = form.get("Image") as File | null;
|
||||||
|
const hasImage = imageFile && imageFile.size > 0;
|
||||||
|
|
||||||
|
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",
|
||||||
|
User: user.id,
|
||||||
|
};
|
||||||
|
if (hasImage) body.Image = imageFile;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await locals.pb.collection(COLLECTION).create(body);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
err && typeof err === "object" && "message" in err
|
||||||
|
? String((err as { message: string }).message)
|
||||||
|
: "Failed to create receipt.";
|
||||||
|
return fail(500, { error: message, Status, Type });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,18 +2,44 @@
|
|||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
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";
|
||||||
|
|
||||||
let { data }: { data: { receipts: { id: string }[] } } = $props();
|
let { data, form }: { data: { receipts: StackqReceipt[] }; form?: ActionData } = $props();
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
|
let createDialogOpen = $state(false);
|
||||||
|
|
||||||
const receipts = $derived(data?.receipts ?? []);
|
const receipts = $derived(data?.receipts ?? []);
|
||||||
const filteredReceipts = $derived(
|
const filteredReceipts = $derived(
|
||||||
(() => {
|
(() => {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
if (!q) return receipts;
|
if (!q) return receipts;
|
||||||
return receipts.filter((r) => r.id.toLowerCase().includes(q));
|
return receipts.filter(
|
||||||
|
(r) =>
|
||||||
|
r.id.toLowerCase().includes(q) ||
|
||||||
|
(r.Status ?? "").toLowerCase().includes(q) ||
|
||||||
|
(r.Type ?? "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReceiptCreateSuccess() {
|
||||||
|
createDialogOpen = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -38,9 +64,9 @@
|
|||||||
<span class="text-muted-foreground text-sm shrink-0">
|
<span class="text-muted-foreground text-sm shrink-0">
|
||||||
{filteredReceipts.length}{filteredReceipts.length !== receipts.length ? ` of ${receipts.length}` : ""} receipts
|
{filteredReceipts.length}{filteredReceipts.length !== receipts.length ? ` of ${receipts.length}` : ""} receipts
|
||||||
</span>
|
</span>
|
||||||
<a href="/receipts/new" class="block shrink-0">
|
<Button class="w-full sm:w-auto min-h-11" onclick={() => (createDialogOpen = true)}>
|
||||||
<Button class="w-full sm:w-auto min-h-11">New receipt</Button>
|
New receipt
|
||||||
</a>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,9 +79,7 @@
|
|||||||
<p class="text-muted-foreground text-center text-xs">
|
<p class="text-muted-foreground text-center text-xs">
|
||||||
{receipts.length === 0 ? "Create a receipt to get started." : "Try a different search."}
|
{receipts.length === 0 ? "Create a receipt to get started." : "Try a different search."}
|
||||||
</p>
|
</p>
|
||||||
<a href="/receipts/new">
|
<Button variant="outline" onclick={() => (createDialogOpen = true)}>New receipt</Button>
|
||||||
<Button variant="outline">New receipt</Button>
|
|
||||||
</a>
|
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<Button variant="ghost">Back to home</Button>
|
<Button variant="ghost">Back to home</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -66,10 +90,30 @@
|
|||||||
{#each filteredReceipts as receipt (receipt.id)}
|
{#each filteredReceipts as receipt (receipt.id)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="p-4">
|
<CardContent class="p-4">
|
||||||
<a href="/receipts/{receipt.id}" class="font-medium hover:underline">Receipt {receipt.id}</a>
|
<a href="/receipts/{receipt.id}" class="font-medium hover:underline">
|
||||||
|
{receipt.Status ?? "—"} · {receipt.Type ?? "—"} · {formatDate(receipt.created)}
|
||||||
|
</a>
|
||||||
|
<p class="text-muted-foreground mt-1 text-xs">ID: {receipt.id}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={createDialogOpen}>
|
||||||
|
<Dialog.Content class="max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] overflow-y-auto rounded-lg sm:w-full">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>New receipt</Dialog.Title>
|
||||||
|
<Dialog.Description>Add a new receipt. Optionally attach an image.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<ReceiptForm
|
||||||
|
action="/receipts?/create"
|
||||||
|
submitLabel="Create"
|
||||||
|
error={form?.error}
|
||||||
|
defaultStatus={form?.Status ?? "New"}
|
||||||
|
defaultType={form?.Type ?? "Purchase"}
|
||||||
|
onSuccess={onReceiptCreateSuccess}
|
||||||
|
/>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ["New", "Data Entered", "Reviewed", "Consolidated"] as const;
|
||||||
|
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
|
||||||
|
|
||||||
|
let {
|
||||||
|
action,
|
||||||
|
submitLabel,
|
||||||
|
error,
|
||||||
|
onSuccess,
|
||||||
|
defaultStatus = "New",
|
||||||
|
defaultType = "Purchase",
|
||||||
|
}: {
|
||||||
|
action: string;
|
||||||
|
submitLabel: string;
|
||||||
|
error?: string | null;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
defaultStatus?: string;
|
||||||
|
defaultType?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let imageInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let imageFileName = $state<string | null>(null);
|
||||||
|
|
||||||
|
function openScanReceipt() {
|
||||||
|
imageInputEl?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageChange(e: Event) {
|
||||||
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
imageFileName = file ? file.name : null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={action}
|
||||||
|
use:enhance={() => async ({ update, result }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === "success" && result.data?.success) {
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<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={defaultStatus === 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={defaultType === o}>{o}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="Image">Image</Label>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full"
|
||||||
|
onclick={openScanReceipt}
|
||||||
|
>
|
||||||
|
Scan receipt
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
bind:this={imageInputEl}
|
||||||
|
id="Image"
|
||||||
|
name="Image"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onchange={onImageChange}
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
{#if imageFileName}
|
||||||
|
<p class="text-muted-foreground text-xs">Selected: {imageFileName}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground text-xs">Optional. Use “Scan receipt” to take a photo or pick an image.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-destructive text-sm">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="submit">{submitLabel}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
import type { StackqReceipt } from "$lib/types/receipts";
|
||||||
|
|
||||||
|
const COLLECTION = "Stackq_Receipts";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
const { id } = params;
|
||||||
|
try {
|
||||||
|
const record = await locals.pb
|
||||||
|
.collection(COLLECTION)
|
||||||
|
.getOne<StackqReceipt>(id, { expand: "User" });
|
||||||
|
return { receipt: record };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const is404 =
|
||||||
|
err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"status" in err &&
|
||||||
|
(err as { status: number }).status === 404;
|
||||||
|
if (is404) throw error(404, "Receipt not found");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,12 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
|
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
|
||||||
|
import type { StackqReceipt } from "$lib/types/receipts";
|
||||||
|
|
||||||
let { data, params }: { data: unknown; params: { id: string } } = $props();
|
const COLLECTION = "Stackq_Receipts";
|
||||||
|
|
||||||
|
let { data }: { data: { receipt: StackqReceipt } } = $props();
|
||||||
|
|
||||||
|
const receipt = $derived(data?.receipt);
|
||||||
|
|
||||||
|
function imageUrl(r: StackqReceipt): string | null {
|
||||||
|
if (!r?.Image) return null;
|
||||||
|
return `${POCKETBASE_BASE_URL}/api/files/${COLLECTION}/${r.id}/${r.Image}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Receipt – Stackq</title>
|
<title>Receipt {receipt?.Status ?? receipt?.id ?? "—"} – Stackq</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
|
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
|
||||||
@@ -15,12 +37,75 @@
|
|||||||
<h1 class="text-xl font-semibold sm:text-2xl">Receipt</h1>
|
<h1 class="text-xl font-semibold sm:text-2xl">Receipt</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
{#if receipt}
|
||||||
<CardContent class="flex flex-col gap-4 py-8">
|
<Card>
|
||||||
<p class="text-muted-foreground text-sm">Receipt {params.id}</p>
|
<CardContent class="flex flex-col gap-4 py-6">
|
||||||
<a href="/receipts">
|
<dl class="grid gap-3 text-sm">
|
||||||
<Button variant="outline">Back to receipts</Button>
|
<div class="flex gap-2">
|
||||||
</a>
|
<dt class="text-muted-foreground w-24 shrink-0">Status</dt>
|
||||||
</CardContent>
|
<dd>{receipt.Status ?? "—"}</dd>
|
||||||
</Card>
|
</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}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-muted-foreground w-24 shrink-0">Image</dt>
|
||||||
|
<dd>
|
||||||
|
{@const url = imageUrl(receipt)}
|
||||||
|
{#if url}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt="Receipt"
|
||||||
|
class="max-h-48 rounded border object-contain"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
<a href="/receipts">
|
||||||
|
<Button variant="outline">Back to receipts</Button>
|
||||||
|
</a>
|
||||||
|
</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>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user