135 lines
4.8 KiB
Svelte
135 lines
4.8 KiB
Svelte
<script lang="ts">
|
||
import { Badge } from "$lib/components/ui/badge";
|
||
import { Button } from "$lib/components/ui/button";
|
||
import { Card, CardContent } from "$lib/components/ui/card";
|
||
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, form }: { data: { receipts: StackqReceipt[] }; form?: ActionData } = $props();
|
||
let searchQuery = $state("");
|
||
let createDialogOpen = $state(false);
|
||
|
||
const receipts = $derived(data?.receipts ?? []);
|
||
const filteredReceipts = $derived(
|
||
(() => {
|
||
const q = searchQuery.trim().toLowerCase();
|
||
if (!q) return receipts;
|
||
const userName = (r: StackqReceipt) =>
|
||
(r.expand?.User?.name ?? r.expand?.User?.email ?? "").toLowerCase();
|
||
return receipts.filter(
|
||
(r) =>
|
||
r.id.toLowerCase().includes(q) ||
|
||
(r.Status ?? "").toLowerCase().includes(q) ||
|
||
(r.Type ?? "").toLowerCase().includes(q) ||
|
||
userName(r).includes(q)
|
||
);
|
||
})()
|
||
);
|
||
|
||
function userName(receipt: StackqReceipt): string {
|
||
const u = receipt.expand?.User;
|
||
return u?.name ?? u?.email ?? "—";
|
||
}
|
||
|
||
/** Format ISO (UTC) date string as local date/time */
|
||
function formatDateLocal(iso: string): string {
|
||
try {
|
||
const d = new Date(iso);
|
||
return d.toLocaleString(undefined, {
|
||
dateStyle: "short",
|
||
timeStyle: "short",
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function onReceiptCreateSuccess() {
|
||
createDialogOpen = false;
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>Receipts – Stackq</title>
|
||
</svelte:head>
|
||
|
||
<main class="container mx-auto flex flex-col gap-6 px-4 py-6 sm:px-6 sm:py-8">
|
||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div class="flex items-center gap-3 min-w-0">
|
||
<a href="/" class="text-muted-foreground hover:text-foreground shrink-0 text-sm" title="Back to main">Back</a>
|
||
<h1 class="text-xl font-semibold truncate sm:text-2xl">Receipts</h1>
|
||
</div>
|
||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2">
|
||
<div class="w-full sm:w-64 sm:min-w-0">
|
||
<Input
|
||
type="search"
|
||
placeholder="Search receipts…"
|
||
bind:value={searchQuery}
|
||
class="h-11 w-full sm:h-9"
|
||
/>
|
||
</div>
|
||
<span class="text-muted-foreground text-sm shrink-0">
|
||
{filteredReceipts.length}{filteredReceipts.length !== receipts.length ? ` of ${receipts.length}` : ""} receipts
|
||
</span>
|
||
<Button class="w-full sm:w-auto min-h-11" onclick={() => (createDialogOpen = true)}>
|
||
New receipt
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{#if filteredReceipts.length === 0}
|
||
<Card>
|
||
<CardContent class="flex flex-col items-center justify-center gap-4 py-12">
|
||
<p class="text-muted-foreground text-center text-sm">
|
||
{receipts.length === 0 ? "No receipts yet." : "No receipts match your search."}
|
||
</p>
|
||
<p class="text-muted-foreground text-center text-xs">
|
||
{receipts.length === 0 ? "Create a receipt to get started." : "Try a different search."}
|
||
</p>
|
||
<Button variant="outline" onclick={() => (createDialogOpen = true)}>New receipt</Button>
|
||
<a href="/">
|
||
<Button variant="ghost">Back to home</Button>
|
||
</a>
|
||
</CardContent>
|
||
</Card>
|
||
{:else}
|
||
<div class="flex flex-col gap-2">
|
||
{#each filteredReceipts as receipt (receipt.id)}
|
||
<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>
|
||
</Card>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</main>
|
||
|
||
<Dialog.Root bind:open={createDialogOpen}>
|
||
<Dialog.Content class="flex min-h-0 max-h-[90dvh] w-[calc(100vw-2rem)] max-w-[500px] flex-col overflow-hidden rounded-lg sm:w-full">
|
||
<Dialog.Header class="shrink-0">
|
||
<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}
|
||
defaultType={form?.Type ?? "Purchase"}
|
||
onSuccess={onReceiptCreateSuccess}
|
||
/>
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|