INIT
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
</script>
|
||||
|
||||
<main class="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 class="text-2xl font-semibold">Stackq</h1>
|
||||
<p class="text-muted-foreground">Svelte 5 + shadcn-svelte + PocketBase</p>
|
||||
<div class="flex gap-2">
|
||||
<a href="/items">
|
||||
<Button>Items table</Button>
|
||||
</a>
|
||||
<a href="/logout">
|
||||
<Button variant="outline">Log out</Button>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,124 @@
|
||||
import { fail } from "@sveltejs/kit";
|
||||
import type { Actions, PageServerLoad } from "./$types";
|
||||
import type { StackqItem } from "$lib/types/items";
|
||||
|
||||
const COLLECTION = "Stackq_Items";
|
||||
|
||||
function parseNum(val: unknown): number | undefined {
|
||||
if (val === "" || val === null || val === undefined) return undefined;
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseBool(val: unknown): boolean {
|
||||
return val === "on" || val === "true" || val === true;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const list = await locals.pb
|
||||
.collection(COLLECTION)
|
||||
.getFullList<StackqItem>({
|
||||
sort: "-created",
|
||||
expand: "Parent",
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(COLLECTION, err);
|
||||
return [] as StackqItem[];
|
||||
});
|
||||
|
||||
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
|
||||
|
||||
return { items: list, itemsForParent };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals }) => {
|
||||
const form = await request.formData();
|
||||
const Item = (form.get("Item") as string)?.trim() ?? "";
|
||||
const SKU = (form.get("SKU") as string)?.trim() ?? "";
|
||||
const Description = (form.get("Description") as string)?.trim() ?? "";
|
||||
const UOM = (form.get("UOM") as string)?.trim() ?? "";
|
||||
const Cost = parseNum(form.get("Cost"));
|
||||
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
|
||||
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
|
||||
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
|
||||
const Has_Parent = parseBool(form.get("Has_Parent"));
|
||||
const Parent = (form.get("Parent") as string)?.trim() ?? "";
|
||||
const Stock = parseBool(form.get("Stock"));
|
||||
|
||||
if (!Item) return fail(400, { error: "Item name is required", form: "create" });
|
||||
|
||||
try {
|
||||
await locals.pb.collection(COLLECTION).create({
|
||||
Item,
|
||||
SKU,
|
||||
Description,
|
||||
UOM: UOM || undefined,
|
||||
Cost: Cost ?? 0,
|
||||
Vendor,
|
||||
Catagory: Catagory || undefined,
|
||||
Sub_Catagory: Sub_Catagory || undefined,
|
||||
Has_Parent: !!Has_Parent,
|
||||
Parent: Parent || null,
|
||||
Stock: !!Stock,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Create failed";
|
||||
return fail(500, { error: message, form: "create" });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
update: async ({ request, locals }) => {
|
||||
const form = await request.formData();
|
||||
const id = (form.get("id") as string)?.trim();
|
||||
if (!id) return fail(400, { error: "Missing id", form: "update" });
|
||||
|
||||
const Item = (form.get("Item") as string)?.trim() ?? "";
|
||||
const SKU = (form.get("SKU") as string)?.trim() ?? "";
|
||||
const Description = (form.get("Description") as string)?.trim() ?? "";
|
||||
const UOM = (form.get("UOM") as string)?.trim() ?? "";
|
||||
const Cost = parseNum(form.get("Cost"));
|
||||
const Vendor = (form.get("Vendor") as string)?.trim() ?? "";
|
||||
const Catagory = (form.get("Catagory") as string)?.trim() ?? "";
|
||||
const Sub_Catagory = (form.get("Sub_Catagory") as string)?.trim() ?? "";
|
||||
const Has_Parent = parseBool(form.get("Has_Parent"));
|
||||
const Parent = (form.get("Parent") as string)?.trim() ?? "";
|
||||
const Stock = parseBool(form.get("Stock"));
|
||||
|
||||
if (!Item) return fail(400, { error: "Item name is required", form: "update" });
|
||||
|
||||
try {
|
||||
await locals.pb.collection(COLLECTION).update(id, {
|
||||
Item,
|
||||
SKU,
|
||||
Description,
|
||||
UOM: UOM || undefined,
|
||||
Cost: Cost ?? 0,
|
||||
Vendor,
|
||||
Catagory: Catagory || undefined,
|
||||
Sub_Catagory: Sub_Catagory || undefined,
|
||||
Has_Parent: !!Has_Parent,
|
||||
Parent: Parent || null,
|
||||
Stock: !!Stock,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Update failed";
|
||||
return fail(500, { error: message, form: "update" });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
const form = await request.formData();
|
||||
const id = (form.get("id") as string)?.trim();
|
||||
if (!id) return fail(400, { error: "Missing id", form: "delete" });
|
||||
try {
|
||||
await locals.pb.collection(COLLECTION).delete(id);
|
||||
} catch (e: unknown) {
|
||||
const message = e && typeof e === "object" && "message" in e ? String((e as { message: string }).message) : "Delete failed";
|
||||
return fail(500, { error: message, form: "delete" });
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
import type { StackqItem } from "$lib/types/items";
|
||||
|
||||
let { data }: { data: { items: StackqItem[]; itemsForParent: { id: string; Item: string; SKU: string }[] } } = $props();
|
||||
|
||||
function asList(val: string | string[] | undefined): string[] {
|
||||
if (val == null) return [];
|
||||
return Array.isArray(val) ? val : [val];
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
const items = $derived(data?.items ?? []);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Items – Stackq</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if browser}
|
||||
{#await import("./ItemsCrud.svelte")}
|
||||
<main class="container mx-auto flex flex-col gap-6 py-8">
|
||||
<p class="text-muted-foreground">Loading…</p>
|
||||
</main>
|
||||
{:then { default: ItemsCrud }}
|
||||
<ItemsCrud data={data} />
|
||||
{/await}
|
||||
{:else}
|
||||
<main class="container mx-auto flex flex-col gap-6 py-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Stackq Items</h1>
|
||||
<span class="text-muted-foreground text-sm">{items.length} items</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stackq_Items</CardTitle>
|
||||
<p class="text-muted-foreground text-sm">PocketBase collection. Enable JS for create/edit/delete.</p>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Item</Table.Head>
|
||||
<Table.Head>SKU</Table.Head>
|
||||
<Table.Head class="max-w-[200px]">Description</Table.Head>
|
||||
<Table.Head>UOM</Table.Head>
|
||||
<Table.Head class="text-right">Cost</Table.Head>
|
||||
<Table.Head>Vendor</Table.Head>
|
||||
<Table.Head>Catagory</Table.Head>
|
||||
<Table.Head>Sub_Catagory</Table.Head>
|
||||
<Table.Head>Has Parent</Table.Head>
|
||||
<Table.Head>Parent</Table.Head>
|
||||
<Table.Head>Stock</Table.Head>
|
||||
<Table.Head class="text-muted-foreground">created</Table.Head>
|
||||
<Table.Head class="text-muted-foreground">updated</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if items.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan="13" class="h-24 text-center text-muted-foreground">
|
||||
No items.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each items as item (item.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{item.Item ?? "—"}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{item.SKU ?? "—"}</Table.Cell>
|
||||
<Table.Cell class="max-w-[200px] truncate" title={item.Description ?? ""}>
|
||||
{item.Description ?? "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{item.UOM ?? "—"}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">
|
||||
{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{item.Vendor ?? "—"}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each asList(item.Catagory) as cat}
|
||||
<Badge variant="secondary" class="mr-1 text-xs">{cat}</Badge>
|
||||
{/each}
|
||||
{#if asList(item.Catagory).length === 0}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each asList(item.Sub_Catagory) as sub}
|
||||
<Badge variant="outline" class="mr-1 text-xs">{sub}</Badge>
|
||||
{/each}
|
||||
{#if asList(item.Sub_Catagory).length === 0}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if item.Has_Parent}
|
||||
<Badge>Yes</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">
|
||||
{#if item.expand?.Parent}
|
||||
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent}
|
||||
{:else if item.Parent}
|
||||
<span class="font-mono text-xs">{item.Parent}</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if item.Stock}
|
||||
<Badge variant="default">Stock</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">{formatDate(item.created)}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">{formatDate(item.updated)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
{/if}
|
||||
@@ -0,0 +1,173 @@
|
||||
<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";
|
||||
import { UOM_OPTIONS, CATAGORY_OPTIONS, SUB_CATAGORY_OPTIONS } from "$lib/constants/items";
|
||||
import type { StackqItem } from "$lib/types/items";
|
||||
|
||||
let {
|
||||
item = null,
|
||||
itemsForParent = [],
|
||||
action,
|
||||
submitLabel,
|
||||
error,
|
||||
onSuccess,
|
||||
}: {
|
||||
item?: StackqItem | null;
|
||||
itemsForParent?: { id: string; Item: string; SKU: string }[];
|
||||
action: string;
|
||||
submitLabel: string;
|
||||
error?: string | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const parentOptions = $derived(
|
||||
item ? itemsForParent.filter((p) => p.id !== item!.id) : itemsForParent
|
||||
);
|
||||
const defaultItem = $derived(item?.Item ?? "");
|
||||
const defaultSku = $derived(item?.SKU ?? "");
|
||||
const defaultDesc = $derived(item?.Description ?? "");
|
||||
const defaultUom = $derived(item?.UOM ?? "");
|
||||
const defaultCost = $derived(item?.Cost != null ? String(item.Cost) : "");
|
||||
const defaultVendor = $derived(item?.Vendor ?? "");
|
||||
const defaultCat = $derived(Array.isArray(item?.Catagory) ? item!.Catagory[0] : item?.Catagory ?? "");
|
||||
const defaultSub = $derived(Array.isArray(item?.Sub_Catagory) ? item!.Sub_Catagory[0] : item?.Sub_Catagory ?? "");
|
||||
const defaultParent = $derived(item?.Parent ?? "");
|
||||
const defaultHasParent = $derived(!!item?.Has_Parent);
|
||||
const defaultStock = $derived(!!item?.Stock);
|
||||
</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"
|
||||
>
|
||||
{#if isEdit}
|
||||
<input type="hidden" name="id" value={item!.id} />
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="Item">Item</Label>
|
||||
<Input id="Item" name="Item" value={defaultItem} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="SKU">SKU</Label>
|
||||
<Input id="SKU" name="SKU" value={defaultSku} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="Description">Description</Label>
|
||||
<Input id="Description" name="Description" value={defaultDesc} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="UOM">UOM</Label>
|
||||
<select
|
||||
id="UOM"
|
||||
name="UOM"
|
||||
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each UOM_OPTIONS as o}
|
||||
<option value={o} selected={defaultUom === o}>{o}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="Cost">Cost</Label>
|
||||
<Input id="Cost" name="Cost" type="number" step="any" value={defaultCost} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="Vendor">Vendor</Label>
|
||||
<Input id="Vendor" name="Vendor" value={defaultVendor} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="Catagory">Catagory</Label>
|
||||
<select
|
||||
id="Catagory"
|
||||
name="Catagory"
|
||||
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each CATAGORY_OPTIONS as o}
|
||||
<option value={o} selected={defaultCat === o}>{o}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="Sub_Catagory">Sub_Catagory</Label>
|
||||
<select
|
||||
id="Sub_Catagory"
|
||||
name="Sub_Catagory"
|
||||
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each SUB_CATAGORY_OPTIONS as o}
|
||||
<option value={o} selected={defaultSub === o}>{o}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="Parent">Parent</Label>
|
||||
<select
|
||||
id="Parent"
|
||||
name="Parent"
|
||||
class="border-input bg-background flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each parentOptions as p}
|
||||
<option value={p.id} selected={defaultParent === p.id}>
|
||||
{p.Item || p.SKU || p.id}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="Has_Parent"
|
||||
value="on"
|
||||
checked={defaultHasParent}
|
||||
class="border-input size-4 rounded"
|
||||
/>
|
||||
Has Parent
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="Stock"
|
||||
value="on"
|
||||
checked={defaultStock}
|
||||
class="border-input size-4 rounded"
|
||||
/>
|
||||
Stock
|
||||
</label>
|
||||
</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,247 @@
|
||||
<script lang="ts">
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
|
||||
import { enhance } from "$app/forms";
|
||||
import ItemForm from "./ItemForm.svelte";
|
||||
import { Ellipsis, Pencil, Plus, Trash2 } from "@lucide/svelte";
|
||||
import type { StackqItem } from "$lib/types/items";
|
||||
|
||||
let {
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
items: StackqItem[];
|
||||
itemsForParent: { id: string; Item: string; SKU: string }[];
|
||||
};
|
||||
} = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let editingItem = $state<StackqItem | null>(null);
|
||||
let formWhich = $state<"create" | "update">("create");
|
||||
|
||||
const items = $derived(data?.items ?? []);
|
||||
const itemsForParent = $derived(data?.itemsForParent ?? []);
|
||||
|
||||
function asList(val: string | string[] | undefined): string[] {
|
||||
if (val == null) return [];
|
||||
return Array.isArray(val) ? val : [val];
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingItem = null;
|
||||
formWhich = "create";
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(item: StackqItem) {
|
||||
editingItem = item;
|
||||
formWhich = "update";
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function onFormSuccess() {
|
||||
dialogOpen = false;
|
||||
editingItem = null;
|
||||
}
|
||||
|
||||
const formError = $derived(
|
||||
data?.form === "create" || data?.form === "update" ? (data?.error as string | undefined) ?? null : null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (formError && (data?.form === "create" || data?.form === "update")) {
|
||||
dialogOpen = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Items – Stackq</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="container mx-auto flex flex-col gap-6 py-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Stackq Items</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-sm">{items.length} items</span>
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="mr-2 size-4" />
|
||||
New item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stackq_Items</CardTitle>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
PocketBase collection: full CRUD.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Item</Table.Head>
|
||||
<Table.Head>SKU</Table.Head>
|
||||
<Table.Head class="max-w-[200px]">Description</Table.Head>
|
||||
<Table.Head>UOM</Table.Head>
|
||||
<Table.Head class="text-right">Cost</Table.Head>
|
||||
<Table.Head>Vendor</Table.Head>
|
||||
<Table.Head>Catagory</Table.Head>
|
||||
<Table.Head>Sub_Catagory</Table.Head>
|
||||
<Table.Head>Has Parent</Table.Head>
|
||||
<Table.Head>Parent</Table.Head>
|
||||
<Table.Head>Stock</Table.Head>
|
||||
<Table.Head class="text-muted-foreground">created</Table.Head>
|
||||
<Table.Head class="text-muted-foreground">updated</Table.Head>
|
||||
<Table.Head class="w-[70px]">Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if items.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan="14" class="h-24 text-center text-muted-foreground">
|
||||
No items. Create one or check PocketBase collection name and auth.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each items as item (item.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{item.Item ?? "—"}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{item.SKU ?? "—"}</Table.Cell>
|
||||
<Table.Cell class="max-w-[200px] truncate" title={item.Description ?? ""}>
|
||||
{item.Description ?? "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{item.UOM ?? "—"}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">
|
||||
{item.Cost != null ? Number(item.Cost).toLocaleString() : "—"}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">{item.Vendor ?? "—"}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each asList(item.Catagory) as cat}
|
||||
<Badge variant="secondary" class="mr-1 text-xs">{cat}</Badge>
|
||||
{/each}
|
||||
{#if asList(item.Catagory).length === 0}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#each asList(item.Sub_Catagory) as sub}
|
||||
<Badge variant="outline" class="mr-1 text-xs">{sub}</Badge>
|
||||
{/each}
|
||||
{#if asList(item.Sub_Catagory).length === 0}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if item.Has_Parent}
|
||||
<Badge>Yes</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground">
|
||||
{#if item.expand?.Parent}
|
||||
{item.expand.Parent.Item ?? item.expand.Parent.SKU ?? item.Parent}
|
||||
{:else if item.Parent}
|
||||
<span class="font-mono text-xs">{item.Parent}</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if item.Stock}
|
||||
<Badge variant="default">Stock</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{formatDate(item.created)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{formatDate(item.updated)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="border-0 bg-transparent hover:bg-muted inline-flex size-8 items-center justify-center rounded-md"
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEdit(item)}>
|
||||
<Pencil class="mr-2 size-4" />
|
||||
Edit
|
||||
</DropdownMenu.Item>
|
||||
<form
|
||||
id="delete-form-{item.id}"
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance
|
||||
class="hidden"
|
||||
>
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
</form>
|
||||
<DropdownMenu.Item
|
||||
class="text-destructive focus:text-destructive cursor-pointer"
|
||||
onclick={() => {
|
||||
if (confirm("Delete this item? This cannot be undone.")) {
|
||||
document.getElementById("delete-form-" + item.id)?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{editingItem ? "Edit item" : "New item"}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{editingItem ? "Update the item below." : "Add a new Stackq item."}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<ItemForm
|
||||
item={editingItem}
|
||||
itemsForParent={itemsForParent}
|
||||
action={editingItem ? "?/update" : "?/create"}
|
||||
submitLabel={editingItem ? "Update" : "Create"}
|
||||
error={formError ?? null}
|
||||
onSuccess={onFormSuccess}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</main>
|
||||
@@ -0,0 +1,108 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 72.2% 50.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-ring: var(--ring);
|
||||
--color-radius: var(--radius);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fail, redirect } from "@sveltejs/kit";
|
||||
import type { Actions, PageServerLoad } from "./$types";
|
||||
|
||||
function safeRedirectTo(searchParams: URLSearchParams): string {
|
||||
const to = searchParams.get("redirectTo");
|
||||
if (!to || typeof to !== "string") return "/";
|
||||
if (!to.startsWith("/") || to.startsWith("//")) return "/";
|
||||
return to;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = ({ url, locals }) => {
|
||||
if (locals.user) {
|
||||
throw redirect(303, safeRedirectTo(url.searchParams));
|
||||
}
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
login: async ({ request, locals, url }) => {
|
||||
const data = await request.formData();
|
||||
const email = (data.get("email") as string)?.trim() ?? "";
|
||||
const password = (data.get("password") as string) ?? "";
|
||||
|
||||
if (!email || !password) {
|
||||
return fail(400, { error: "Email and password are required.", email });
|
||||
}
|
||||
|
||||
try {
|
||||
await locals.pb.collection("users").authWithPassword(email, password);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err && typeof err === "object" && "message" in err
|
||||
? String((err as { message: string }).message)
|
||||
: "Invalid email or password.";
|
||||
return fail(401, { error: message, email });
|
||||
}
|
||||
|
||||
const redirectTo = safeRedirectTo(url.searchParams);
|
||||
throw redirect(303, redirectTo);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import type { ActionData } from "./$types";
|
||||
|
||||
let { data }: { data: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<main class="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
{#if browser}
|
||||
{#await import("./LoginForm.svelte")}
|
||||
<p class="text-muted-foreground">Loading…</p>
|
||||
{:then { default: LoginForm }}
|
||||
<LoginForm data={data} />
|
||||
{/await}
|
||||
{:else}
|
||||
<div class="w-full max-w-sm space-y-4 rounded-xl border bg-card p-6 shadow-sm">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-lg font-semibold">Login</h1>
|
||||
<p class="text-muted-foreground text-sm">Enter your email and password to sign in.</p>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">Loading form…</p>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
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";
|
||||
import type { ActionData } from "./$types";
|
||||
|
||||
let { data }: { data: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root class="w-full max-w-sm">
|
||||
<Card.Header>
|
||||
<Card.Title>Login</Card.Title>
|
||||
<Card.Description>Enter your email and password to sign in.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/login"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update();
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
value={data?.email ?? ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="password">Password</Label>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="text-muted-foreground text-sm underline hover:text-foreground"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{#if data?.error}
|
||||
<p class="text-destructive text-sm">{data.error}</p>
|
||||
{/if}
|
||||
<Button type="submit" class="w-full">Login</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
locals.pb.authStore.clear();
|
||||
throw redirect(303, "/login");
|
||||
};
|
||||
Reference in New Issue
Block a user