Enhance receipt management by adding a Description field to receipts, improving user input validation and error handling in the server actions. Update UI components to support the new Description field, ensuring a more comprehensive receipt creation and editing experience.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m49s

This commit is contained in:
eewing
2026-02-26 11:10:58 -06:00
parent b72df41ec7
commit e3d96b8818
7 changed files with 165 additions and 56 deletions
@@ -10,11 +10,16 @@ const COLLECTION = "Stackq_Receipts";
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
export const load = async ({ locals }: Parameters<PageServerLoad>[0]) => {
const user = locals.user as { id: string; Admin?: boolean } | null;
if (!user?.id) return { receipts: [] as StackqReceipt[] };
const isAdmin = user.Admin === true;
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqReceipt>({
sort: "-created",
expand: "User",
...(isAdmin ? {} : { filter: `User = "${user.id}"` }),
})
.catch((err) => {
console.error(COLLECTION, err);
@@ -31,11 +36,15 @@ export const actions = {
const form = await request.formData();
const Type = (form.get("Type") as string)?.trim() || "Purchase";
const Description = (form.get("Description") as string)?.trim() ?? "";
if (!Description) {
return fail(400, { error: "Description is required.", Type });
}
const imageFile = form.get("Image") as File | null;
const hasImage = imageFile && imageFile.size > 0;
if (hasImage) {
const imgError = validateImageUpload(imageFile);
if (imgError) return fail(400, { error: imgError, Type });
if (imgError) return fail(400, { error: imgError, Type, Description });
}
let imageToSave: File | null = hasImage ? imageFile : null;
@@ -46,6 +55,7 @@ export const actions = {
return fail(400, {
error: "Could not convert HEIC image. Try saving as JPEG (iPhone: Settings → Camera → Formats) or use a different photo.",
Type,
Description,
});
}
}
@@ -53,6 +63,7 @@ export const actions = {
const body: Record<string, unknown> = {
Status: "New",
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
Description,
User: user.id,
};
if (imageToSave) body.Image = imageToSave;
@@ -64,7 +75,7 @@ export const actions = {
err && typeof err === "object" && "message" in err
? String((err as { message: string }).message)
: "Failed to create receipt.";
return fail(500, { error: message, Type });
return fail(500, { error: message, Type, Description });
}
return { success: true };
+1
View File
@@ -19,6 +19,7 @@ export interface StackqReceipt {
id: string;
Status: string;
Type: string;
Description?: string;
User: string;
Image?: string;
Data?: ReceiptDataShape | Record<string, unknown>;
+13 -2
View File
@@ -9,11 +9,16 @@ const COLLECTION = "Stackq_Receipts";
const TYPE_OPTIONS = ["Purchase", "Gas"] as const;
export const load: PageServerLoad = async ({ locals }) => {
const user = locals.user as { id: string; Admin?: boolean } | null;
if (!user?.id) return { receipts: [] as StackqReceipt[] };
const isAdmin = user.Admin === true;
const list = await locals.pb
.collection(COLLECTION)
.getFullList<StackqReceipt>({
sort: "-created",
expand: "User",
...(isAdmin ? {} : { filter: `User = "${user.id}"` }),
})
.catch((err) => {
console.error(COLLECTION, err);
@@ -30,11 +35,15 @@ export const actions: Actions = {
const form = await request.formData();
const Type = (form.get("Type") as string)?.trim() || "Purchase";
const Description = (form.get("Description") as string)?.trim() ?? "";
if (!Description) {
return fail(400, { error: "Description is required.", Type });
}
const imageFile = form.get("Image") as File | null;
const hasImage = imageFile && imageFile.size > 0;
if (hasImage) {
const imgError = validateImageUpload(imageFile);
if (imgError) return fail(400, { error: imgError, Type });
if (imgError) return fail(400, { error: imgError, Type, Description });
}
let imageToSave: File | null = hasImage ? imageFile : null;
@@ -45,6 +54,7 @@ export const actions: Actions = {
return fail(400, {
error: "Could not convert HEIC image. Try saving as JPEG (iPhone: Settings → Camera → Formats) or use a different photo.",
Type,
Description,
});
}
}
@@ -52,6 +62,7 @@ export const actions: Actions = {
const body: Record<string, unknown> = {
Status: "New",
Type: TYPE_OPTIONS.includes(Type as (typeof TYPE_OPTIONS)[number]) ? Type : "Purchase",
Description,
User: user.id,
};
if (imageToSave) body.Image = imageToSave;
@@ -63,7 +74,7 @@ export const actions: Actions = {
err && typeof err === "object" && "message" in err
? String((err as { message: string }).message)
: "Failed to create receipt.";
return fail(500, { error: message, Type });
return fail(500, { error: message, Type, Description });
}
return { success: true };
+1
View File
@@ -128,6 +128,7 @@
submitLabel="Create"
error={form?.error}
defaultType={form?.Type ?? "Purchase"}
defaultDescription={form?.Description ?? ""}
onSuccess={onReceiptCreateSuccess}
/>
</Dialog.Content>
+40 -7
View File
@@ -1,6 +1,8 @@
<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 * as Select from "$lib/components/ui/select";
import { convertImageToWebP, isAcceptableImageFile, isHeicFile } from "$lib/convert-image-to-webp";
import { enhance } from "$app/forms";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
@@ -16,14 +18,30 @@
error,
onSuccess,
defaultType = "Purchase",
defaultDescription = "",
}: {
action: string;
submitLabel: string;
error?: string | null;
onSuccess?: () => void;
defaultType?: string;
defaultDescription?: string;
} = $props();
let typeValue = $state<string>("Purchase");
let typeInitialized = false;
$effect(() => {
if (!typeInitialized) {
typeValue = defaultType;
typeInitialized = true;
}
});
let descriptionValue = $state("");
$effect(() => {
if (error) {
descriptionValue = defaultDescription ?? "";
}
});
let imageFileName = $state<string | null>(null);
let imageConverting = $state(false);
@@ -71,15 +89,30 @@
<div class="min-h-0 flex-1 overflow-y-auto space-y-4">
<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"
>
<input type="hidden" name="Type" value={typeValue} />
<Select.Root type="single" bind:value={typeValue}>
<Select.Trigger id="Type" class="w-full h-9">
<span>{typeValue}</span>
</Select.Trigger>
<Select.Content>
{#each TYPE_OPTIONS as o}
<option value={o} selected={defaultType === o}>{o}</option>
<Select.Item value={o} label={o} />
{/each}
</select>
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label for="Description">Description</Label>
<Input
id="Description"
name="Description"
type="text"
required
bind:value={descriptionValue}
placeholder="materials purchased"
class="w-full"
/>
</div>
<div class="space-y-2">
+23
View File
@@ -14,10 +14,15 @@ const MAX_LINE_ITEMS = 500;
export const load: PageServerLoad = async ({ params, locals }) => {
const { id } = params;
const user = locals.user as { id: string; Admin?: boolean } | null;
try {
const record = await locals.pb
.collection(COLLECTION)
.getOne<StackqReceipt>(id, { expand: "User" });
const isAdmin = user?.Admin === true;
if (!isAdmin && user?.id && record.User !== user.id) {
throw error(404, "Receipt not found");
}
return { receipt: record };
} catch (err: unknown) {
const is404 =
@@ -34,10 +39,21 @@ export const actions: Actions = {
update: async ({ request, locals, params }) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "update" });
const user = locals.user as { id: string; Admin?: boolean } | null;
const isAdmin = user?.Admin === true;
try {
const existing = await locals.pb.collection(COLLECTION).getOne<StackqReceipt>(id);
if (!isAdmin && user?.id && existing.User !== user.id) {
return fail(404, { error: "Receipt not found", form: "update" });
}
} catch {
return fail(404, { error: "Receipt not found", 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 Description = (form.get("Description") as string)?.trim() ?? "";
const dataJson = (form.get("dataJson") as string)?.trim() ?? "{}";
const imageFile = form.get("Image") as File | null;
const hasImage = imageFile && imageFile.size > 0;
@@ -94,6 +110,7 @@ export const actions: Actions = {
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",
Description: Description || undefined,
Data: Object.keys(Data).length ? Data : undefined,
};
if (imageToSave) body.Image = imageToSave;
@@ -113,7 +130,13 @@ export const actions: Actions = {
delete: async ({ locals, params }) => {
const id = params.id;
if (!id) return fail(400, { error: "Missing id", form: "delete" });
const user = locals.user as { id: string; Admin?: boolean } | null;
const isAdmin = user?.Admin === true;
try {
const existing = await locals.pb.collection(COLLECTION).getOne<StackqReceipt>(id);
if (!isAdmin && user?.id && existing.User !== user.id) {
return fail(404, { error: "Receipt not found", form: "delete" });
}
await locals.pb.collection(COLLECTION).delete(id);
} catch (e: unknown) {
const message =
+68 -39
View File
@@ -5,6 +5,8 @@
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "$lib/components/ui/table";
import { convertImageToWebP, isAcceptableImageFile, isHeicFile } from "$lib/convert-image-to-webp";
import { POCKETBASE_BASE_URL } from "$lib/pocketbase";
import { enhance } from "$app/forms";
@@ -120,6 +122,18 @@
return JSON.stringify(data);
}
let statusValue = $state("New");
let typeValue = $state("Purchase");
let descriptionValue = $state("");
$effect(() => {
const r = receipt;
if (r) {
statusValue = (r.Status as string) || "New";
typeValue = (r.Type as string) || "Purchase";
descriptionValue = (r.Description as string) ?? "";
}
});
let imageInputEl = $state<HTMLInputElement | null>(null);
let imageFileName = $state<string | null>(null);
let selectedImageFile = $state<File | null>(null);
@@ -293,29 +307,44 @@
<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"
>
<input type="hidden" name="Status" value={statusValue} />
<Select.Root type="single" bind:value={statusValue}>
<Select.Trigger id="Status" class="w-full h-9">
<span>{statusValue}</span>
</Select.Trigger>
<Select.Content>
{#each STATUS_OPTIONS as o}
<option value={o} selected={receipt.Status === o}>{o}</option>
<Select.Item value={o} label={o} />
{/each}
</select>
</Select.Content>
</Select.Root>
</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"
>
<input type="hidden" name="Type" value={typeValue} />
<Select.Root type="single" bind:value={typeValue}>
<Select.Trigger id="Type" class="w-full h-9">
<span>{typeValue}</span>
</Select.Trigger>
<Select.Content>
{#each TYPE_OPTIONS as o}
<option value={o} selected={receipt.Type === o}>{o}</option>
<Select.Item value={o} label={o} />
{/each}
</select>
</Select.Content>
</Select.Root>
</div>
</div>
<div class="space-y-2">
<Label for="Description">Description</Label>
<Input
id="Description"
name="Description"
type="text"
bind:value={descriptionValue}
placeholder="materials purchased"
class="w-full"
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
@@ -323,52 +352,52 @@
<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>
<Table>
<TableHeader>
<TableRow class="text-left text-muted-foreground">
<TableHead class="pb-2 pr-2">Item</TableHead>
<TableHead class="pb-2 pr-2">Description</TableHead>
<TableHead class="w-20 pb-2 pr-2">Qty</TableHead>
<TableHead class="w-24 pb-2 pr-2">Price</TableHead>
<TableHead class="w-9 pb-2"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each formData.lineItems as row, i}
<tr class="border-b">
<td class="py-1.5 pr-2">
<TableRow>
<TableCell 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">
</TableCell>
<TableCell 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">
</TableCell>
<TableCell 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">
</TableCell>
<TableCell 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">
</TableCell>
<TableCell class="py-1.5">
<Button
type="button"
variant="ghost"
@@ -379,11 +408,11 @@
>
×
</Button>
</td>
</tr>
</TableCell>
</TableRow>
{/each}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>