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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,23 +127,89 @@
|
||||
</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">
|
||||
<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 class="py-8 text-center text-sm text-muted-foreground">
|
||||
No receipts with status “{activeTab}”.
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{: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>
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user