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.
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface StackqReceipt {
|
||||
id: string;
|
||||
Status: string;
|
||||
Type: string;
|
||||
Description?: string;
|
||||
User: string;
|
||||
Image?: string;
|
||||
Data?: ReceiptDataShape | Record<string, unknown>;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
submitLabel="Create"
|
||||
error={form?.error}
|
||||
defaultType={form?.Type ?? "Purchase"}
|
||||
defaultDescription={form?.Description ?? ""}
|
||||
onSuccess={onReceiptCreateSuccess}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user