feat: all route pages — dashboard, scan, items, locations, labels, settings

- Dashboard with stats grid, checked-out/low-stock/recent items
- Scan route with check-in/out flow
- Items list with search, filters (category/type/custody), sorting
- Item detail with full metadata, check-in/out, edit, delete
- New item form wired to inventory store
- Locations browser with tree view and items-at-location
- Labels page with batch generation and PDF download
- Settings page with DB stats
- Fixed Svelte 5 event modifier syntax (no pipe modifiers)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Mühl 2026-02-26 16:22:19 +01:00
parent 0b382b9c5e
commit 1ffdb937ce
11 changed files with 924 additions and 28 deletions

View file

@ -73,7 +73,7 @@
} }
</script> </script>
<form onsubmit|preventDefault={handleSubmit} class="space-y-4 p-4"> <form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 p-4">
<!-- Name --> <!-- Name -->
<div> <div>
<label for="name" class="block text-sm font-medium text-slate-300 mb-1">Name *</label> <label for="name" class="block text-sm font-medium text-slate-300 mb-1">Name *</label>

View file

@ -55,7 +55,7 @@
{#if hasChildren(location.id)} {#if hasChildren(location.id)}
<button <button
class="w-5 h-5 flex items-center justify-center text-slate-400 hover:text-white shrink-0" class="w-5 h-5 flex items-center justify-center text-slate-400 hover:text-white shrink-0"
onclick|stopPropagation={() => toggle(location.id)} onclick={(e) => { e.stopPropagation(); toggle(location.id); }}
aria-label={isExpanded ? 'Collapse' : 'Expand'} aria-label={isExpanded ? 'Collapse' : 'Expand'}
> >
<span class="text-xs transition-transform {isExpanded ? 'rotate-90' : ''}" <span class="text-xs transition-transform {isExpanded ? 'rotate-90' : ''}"

View file

@ -2,13 +2,25 @@
import '../app.css'; import '../app.css';
import BottomNav from '$lib/components/BottomNav.svelte'; import BottomNav from '$lib/components/BottomNav.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { inventory } from '$lib/stores/inventory.svelte';
import { onMount } from 'svelte';
let { children } = $props(); let { children } = $props();
onMount(() => {
inventory.loadAll();
});
</script> </script>
<div class="flex flex-col h-screen bg-slate-900 text-white"> <div class="flex flex-col h-screen bg-slate-900 text-white">
{#if inventory.loading}
<div class="flex-1 flex items-center justify-center">
<div class="text-slate-400 animate-pulse">Loading...</div>
</div>
{:else}
<main class="flex-1 overflow-y-auto pb-16"> <main class="flex-1 overflow-y-auto pb-16">
{@render children()} {@render children()}
</main> </main>
{/if}
<BottomNav currentPath={$page.url.pathname} /> <BottomNav currentPath={$page.url.pathname} />
</div> </div>

View file

@ -1,7 +1,87 @@
<script lang="ts"> <script lang="ts">
import { inventory } from '$lib/stores/inventory.svelte';
import ItemCard from '$lib/components/ItemCard.svelte';
import { goto } from '$app/navigation';
const totalItems = $derived(inventory.items.length);
const checkedOut = $derived(inventory.checkedOutItems.length);
const overdue = $derived(inventory.overdueItems.length);
const lowStock = $derived(inventory.lowStockItems.length);
const stats = $derived([
{ label: 'Total Items', value: totalItems, color: 'text-blue-400' },
{ label: 'Checked Out', value: checkedOut, color: 'text-amber-400' },
{ label: 'Overdue', value: overdue, color: overdue > 0 ? 'text-red-400' : 'text-slate-400' },
{ label: 'Low Stock', value: lowStock, color: lowStock > 0 ? 'text-red-400' : 'text-slate-400' },
]);
const recentItems = $derived(
[...inventory.items]
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, 5)
);
</script> </script>
<div class="p-4"> <div class="p-4 space-y-6">
<h1 class="text-2xl font-bold mb-4">SolidHaus</h1> <div>
<p class="text-slate-400">Household inventory at a glance.</p> <h1 class="text-2xl font-bold">SolidHaus</h1>
<p class="text-slate-400 text-sm">Household inventory at a glance</p>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-2 gap-3">
{#each stats as stat}
<div class="bg-slate-800 rounded-lg p-3">
<div class="text-2xl font-bold {stat.color}">{stat.value}</div>
<div class="text-xs text-slate-400">{stat.label}</div>
</div>
{/each}
</div>
<!-- Checked Out Items -->
{#if inventory.checkedOutItems.length > 0}
<section>
<h2 class="text-sm font-medium text-slate-400 uppercase mb-2">Checked Out</h2>
<div class="space-y-2">
{#each inventory.checkedOutItems.slice(0, 5) as item}
<ItemCard {item} onclick={() => goto(`/items/${item.shortId}`)} />
{/each}
</div>
</section>
{/if}
<!-- Low Stock -->
{#if inventory.lowStockItems.length > 0}
<section>
<h2 class="text-sm font-medium text-slate-400 uppercase mb-2">Low Stock</h2>
<div class="space-y-2">
{#each inventory.lowStockItems.slice(0, 5) as item}
<ItemCard {item} onclick={() => goto(`/items/${item.shortId}`)} />
{/each}
</div>
</section>
{/if}
<!-- Recent Items -->
{#if recentItems.length > 0}
<section>
<h2 class="text-sm font-medium text-slate-400 uppercase mb-2">Recently Updated</h2>
<div class="space-y-2">
{#each recentItems as item}
<ItemCard {item} onclick={() => goto(`/items/${item.shortId}`)} />
{/each}
</div>
</section>
{:else}
<div class="text-center py-8">
<p class="text-slate-500 mb-4">No items yet. Start by scanning or adding an item.</p>
<a
href="/items/new"
class="inline-block px-6 py-3 bg-blue-500 text-white rounded-lg font-medium
hover:bg-blue-600 transition-colors"
>
Add First Item
</a>
</div>
{/if}
</div> </div>

View file

@ -1,7 +1,143 @@
<script lang="ts"> <script lang="ts">
import { inventory } from '$lib/stores/inventory.svelte';
import ItemCard from '$lib/components/ItemCard.svelte';
import { goto } from '$app/navigation';
import type { ItemType, CustodyState } from '$lib/types';
let search = $state('');
let categoryFilter = $state('');
let typeFilter = $state<ItemType | ''>('');
let custodyFilter = $state<CustodyState | ''>('');
let sortBy = $state<'name' | 'updated' | 'custody'>('name');
const categories = $derived(
[...new Set(inventory.items.map((i) => i.category).filter(Boolean))].sort()
);
const filteredItems = $derived(() => {
let items = inventory.items;
if (search) {
const q = search.toLowerCase();
items = items.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.description.toLowerCase().includes(q) ||
i.shortId.includes(q) ||
i.category.toLowerCase().includes(q) ||
i.brand.toLowerCase().includes(q)
);
}
if (categoryFilter) {
items = items.filter((i) => i.category === categoryFilter);
}
if (typeFilter) {
items = items.filter((i) => i.itemType === typeFilter);
}
if (custodyFilter) {
items = items.filter((i) => i.custodyState === custodyFilter);
}
switch (sortBy) {
case 'name':
items = [...items].sort((a, b) => a.name.localeCompare(b.name));
break;
case 'updated':
items = [...items].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
break;
case 'custody':
items = [...items].sort((a, b) => a.custodyState.localeCompare(b.custodyState));
break;
}
return items;
});
</script> </script>
<div class="p-4"> <div class="p-4 space-y-4">
<h1 class="text-2xl font-bold mb-4">Items</h1> <div class="flex items-center justify-between">
<p class="text-slate-400">Browse and manage your inventory.</p> <h1 class="text-2xl font-bold">Items</h1>
<a
href="/items/new"
class="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm font-medium
hover:bg-blue-600 transition-colors"
>
+ New
</a>
</div>
<!-- Search -->
<input
type="search"
bind:value={search}
placeholder="Search items..."
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white
placeholder-slate-500 focus:outline-none focus:border-blue-400"
/>
<!-- Filters -->
<div class="flex gap-2 overflow-x-auto pb-1">
<select
bind:value={categoryFilter}
class="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm text-white shrink-0"
>
<option value="">All Categories</option>
{#each categories as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
<select
bind:value={typeFilter}
class="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm text-white shrink-0"
>
<option value="">All Types</option>
<option value="durable">Durable</option>
<option value="consumable">Consumable</option>
<option value="perishable">Perishable</option>
<option value="disposable">Disposable</option>
<option value="media">Media</option>
<option value="clothing">Clothing</option>
<option value="document">Document</option>
<option value="container">Container</option>
</select>
<select
bind:value={custodyFilter}
class="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm text-white shrink-0"
>
<option value="">All Status</option>
<option value="checked-in">Checked In</option>
<option value="checked-out">Checked Out</option>
</select>
<select
bind:value={sortBy}
class="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm text-white shrink-0"
>
<option value="name">Sort: Name</option>
<option value="updated">Sort: Recent</option>
<option value="custody">Sort: Status</option>
</select>
</div>
<!-- Item List -->
<div class="space-y-2">
{#each filteredItems() as item (item.shortId)}
<ItemCard {item} onclick={() => goto(`/items/${item.shortId}`)} />
{:else}
<p class="text-center text-slate-500 py-8">
{search || categoryFilter || typeFilter || custodyFilter
? 'No items match your filters'
: 'No items yet'}
</p>
{/each}
</div>
<div class="text-center text-xs text-slate-500 pt-2">
{filteredItems().length} of {inventory.items.length} items
</div>
</div> </div>

View file

@ -1,8 +1,239 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { inventory } from '$lib/stores/inventory.svelte';
import ConfidenceBadge from '$lib/components/ConfidenceBadge.svelte';
import CustodyBadge from '$lib/components/CustodyBadge.svelte';
import LocationPicker from '$lib/components/LocationPicker.svelte';
import ItemForm from '$lib/components/ItemForm.svelte';
import { getConfidence } from '$lib/utils/confidence';
import { formatDistanceToNow } from 'date-fns';
import type { Item, CheckOutReason } from '$lib/types';
const item = $derived(
inventory.items.find((i) => i.shortId === $page.params.id)
);
const confidence = $derived(item ? getConfidence(item.lastSeenTimestamp) : 'unknown');
const locationName = $derived(
item ? (inventory.locations.find((l) => l.id === item.lastSeenAt)?.name ?? 'Unknown') : 'Unknown'
);
let editing = $state(false);
let showCheckOut = $state(false);
let checkOutReason = $state<CheckOutReason>('in-use');
let checkOutTo = $state('');
let checkOutNote = $state('');
let checkInLocation = $state('');
let confirmDelete = $state(false);
async function handleUpdate(data: Partial<Item>) {
if (!item) return;
await inventory.updateItem(item.shortId, data);
editing = false;
}
async function handleCheckOut() {
if (!item) return;
await inventory.checkOut(
item.shortId,
checkOutReason,
item.lastSeenAt ?? '',
checkOutTo || null,
checkOutNote
);
showCheckOut = false;
}
async function handleCheckIn() {
if (!item || !checkInLocation) return;
await inventory.checkIn(item.shortId, checkInLocation);
await inventory.recordSighting(item.shortId, checkInLocation, 'manual');
}
async function handleDelete() {
if (!item) return;
await inventory.removeItem(item.shortId);
goto('/items');
}
</script> </script>
<div class="p-4"> {#if !item}
<h1 class="text-2xl font-bold mb-4">Item Detail</h1> <div class="p-4 text-center">
<p class="text-slate-400 font-mono">{$page.params.id}</p> <p class="text-slate-400">Item not found</p>
</div> <a href="/items" class="text-blue-400 text-sm">Back to items</a>
</div>
{:else if editing}
<div class="p-4">
<h1 class="text-2xl font-bold mb-4">Edit Item</h1>
<ItemForm {item} onsubmit={handleUpdate} oncancel={() => (editing = false)} />
</div>
{:else}
<div class="p-4 space-y-4">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<a href="/items" class="text-xs text-slate-500 hover:text-slate-300">&larr; Back</a>
<h1 class="text-2xl font-bold mt-1">{item.name}</h1>
{#if item.category}
<p class="text-sm text-slate-400">{item.category}</p>
{/if}
</div>
<div class="font-mono text-lg text-slate-500 tracking-wider">{item.shortId}</div>
</div>
<!-- Status Badges -->
<div class="flex gap-2">
<CustodyBadge state={item.custodyState} reason={item.checkedOutReason} />
<ConfidenceBadge {confidence} />
</div>
<!-- Details -->
<div class="bg-slate-800 rounded-lg divide-y divide-slate-700">
{#if item.description}
<div class="p-3">
<span class="text-xs text-slate-500">Description</span>
<p class="text-sm text-white">{item.description}</p>
</div>
{/if}
<div class="p-3 flex justify-between">
<span class="text-xs text-slate-500">Location</span>
<span class="text-sm text-white">{locationName}</span>
</div>
{#if item.lastSeenTimestamp}
<div class="p-3 flex justify-between">
<span class="text-xs text-slate-500">Last Seen</span>
<span class="text-sm text-white">
{formatDistanceToNow(new Date(item.lastSeenTimestamp), { addSuffix: true })}
</span>
</div>
{/if}
<div class="p-3 flex justify-between">
<span class="text-xs text-slate-500">Type</span>
<span class="text-sm text-white capitalize">{item.itemType}</span>
</div>
<div class="p-3 flex justify-between">
<span class="text-xs text-slate-500">Storage Tier</span>
<span class="text-sm text-white capitalize">{item.storageTier}</span>
</div>
{#if item.brand}
<div class="p-3 flex justify-between">
<span class="text-xs text-slate-500">Brand</span>
<span class="text-sm text-white">{item.brand}</span>
</div>
{/if}
{#if item.serialNumber}
<div class="p-3 flex justify-between">
<span class="text-xs text-slate-500">Serial</span>
<span class="text-sm text-white font-mono">{item.serialNumber}</span>
</div>
{/if}
{#if item.color}
<div class="p-3 flex justify-between">
<span class="text-xs text-slate-500">Color</span>
<span class="text-sm text-white">{item.color}</span>
</div>
{/if}
</div>
<!-- Quantity (consumable/perishable) -->
{#if item.currentQuantity != null && item.originalQuantity}
{@const percent = Math.round((item.currentQuantity / item.originalQuantity) * 100)}
<div class="bg-slate-800 rounded-lg p-3">
<div class="flex justify-between text-sm mb-2">
<span class="text-slate-400">Quantity</span>
<span class="text-white">
{item.currentQuantity}{item.quantityUnit ? ` ${item.quantityUnit}` : ''} / {item.originalQuantity}
</span>
</div>
<div class="w-full bg-slate-700 rounded-full h-2">
<div
class="h-2 rounded-full {percent <= 20 ? 'bg-red-400' : percent <= 50 ? 'bg-amber-400' : 'bg-emerald-400'}"
style="width: {percent}%"
></div>
</div>
</div>
{/if}
<!-- Check In/Out -->
{#if item.custodyState === 'checked-in'}
{#if showCheckOut}
<div class="bg-slate-800 rounded-lg p-4 space-y-3">
<h3 class="font-medium">Check Out</h3>
<div>
<label for="co-reason" class="block text-xs text-slate-400 mb-1">Reason</label>
<select id="co-reason" bind:value={checkOutReason}
class="w-full bg-slate-700 border border-slate-600 rounded px-3 py-2 text-white text-sm">
<option value="in-use">In Use</option>
<option value="in-transit">In Transit</option>
<option value="lent">Lent</option>
<option value="in-repair">In Repair</option>
<option value="temporary">Temporary</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Destination</label>
<LocationPicker bind:selected={checkOutTo} />
</div>
<div>
<label for="co-note" class="block text-xs text-slate-400 mb-1">Note</label>
<input id="co-note" type="text" bind:value={checkOutNote}
class="w-full bg-slate-700 border border-slate-600 rounded px-3 py-2 text-white text-sm"
placeholder="Optional" />
</div>
<div class="flex gap-2">
<button onclick={handleCheckOut}
class="flex-1 py-2 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600">
Confirm
</button>
<button onclick={() => (showCheckOut = false)}
class="py-2 px-4 border border-slate-600 text-slate-300 rounded-lg hover:bg-slate-700">
Cancel
</button>
</div>
</div>
{:else}
<button onclick={() => (showCheckOut = true)}
class="w-full py-3 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600">
Check Out
</button>
{/if}
{:else}
<div class="bg-slate-800 rounded-lg p-4 space-y-3">
<h3 class="font-medium">Check In</h3>
<div>
<label class="block text-xs text-slate-400 mb-1">Return to</label>
<LocationPicker bind:selected={checkInLocation} />
</div>
<button onclick={handleCheckIn} disabled={!checkInLocation}
class="w-full py-2 bg-emerald-500 text-white rounded-lg font-medium hover:bg-emerald-600
disabled:opacity-50">
Check In
</button>
</div>
{/if}
<!-- Actions -->
<div class="flex gap-3">
<button onclick={() => (editing = true)}
class="flex-1 py-2 border border-slate-600 text-slate-300 rounded-lg hover:bg-slate-700 transition-colors">
Edit
</button>
{#if !confirmDelete}
<button onclick={() => (confirmDelete = true)}
class="py-2 px-4 border border-red-500/30 text-red-400 rounded-lg hover:bg-red-500/10 transition-colors">
Delete
</button>
{:else}
<button onclick={handleDelete}
class="py-2 px-4 bg-red-500 text-white rounded-lg font-medium hover:bg-red-600">
Confirm Delete
</button>
{/if}
</div>
<!-- Barcode URI -->
<div class="text-center pt-2">
<p class="text-xs text-slate-500 font-mono">{item.barcodeUri}</p>
</div>
</div>
{/if}

View file

@ -1,7 +1,50 @@
<script lang="ts"> <script lang="ts">
import ItemForm from '$lib/components/ItemForm.svelte';
import { inventory } from '$lib/stores/inventory.svelte';
import { goto } from '$app/navigation';
import type { Item } from '$lib/types';
async function handleCreate(data: Partial<Item>) {
const item = await inventory.createItem({
name: data.name ?? '',
description: data.description ?? '',
category: data.category ?? '',
brand: data.brand ?? '',
serialNumber: data.serialNumber ?? '',
color: data.color ?? '',
purchaseDate: data.purchaseDate ?? null,
itemType: data.itemType ?? 'durable',
currentQuantity: data.currentQuantity ?? null,
originalQuantity: data.originalQuantity ?? null,
quantityUnit: data.quantityUnit ?? null,
lowThreshold: data.lowThreshold ?? null,
expiryDate: data.expiryDate ?? null,
photoIds: [],
lastSeenAt: data.supposedToBeAt ?? null,
lastSeenTimestamp: data.supposedToBeAt ? new Date().toISOString() : null,
lastUsedAt: null,
supposedToBeAt: data.supposedToBeAt ?? null,
locationConfidence: data.supposedToBeAt ? 'confirmed' : 'unknown',
custodyState: 'checked-in',
checkedOutSince: null,
checkedOutReason: null,
checkedOutFrom: null,
checkedOutTo: null,
checkedOutNote: null,
storageTier: data.storageTier ?? 'warm',
storageContainerId: null,
storageContainerLabel: null,
labelPrinted: false,
labelPrintedAt: null,
labelBatchId: null,
tags: [],
createdBy: null,
});
goto(`/items/${item.shortId}`);
}
</script> </script>
<div class="p-4"> <div class="p-4">
<h1 class="text-2xl font-bold mb-4">New Item</h1> <h1 class="text-2xl font-bold mb-4">New Item</h1>
<p class="text-slate-400">Add a new item to your inventory.</p> <ItemForm onsubmit={handleCreate} oncancel={() => goto('/items')} />
</div> </div>

View file

@ -1,7 +1,134 @@
<script lang="ts"> <script lang="ts">
import { inventory } from '$lib/stores/inventory.svelte';
import { generateBatch, getUnassignedIds } from '$lib/data/labels';
import { generateLabelSheetPDF, downloadBlob } from '$lib/printing/labelSheet';
import { generateItemId } from '$lib/utils/id';
import type { PreGeneratedId } from '$lib/types';
let batchSize = $state(50);
let generating = $state(false);
let unassigned = $state<PreGeneratedId[]>([]);
let error = $state<string | null>(null);
async function loadUnassigned() {
unassigned = await getUnassignedIds();
}
// Load on mount
loadUnassigned();
async function handleGenerateBatch() {
generating = true;
error = null;
try {
const batchId = `batch_${generateItemId()}`;
await generateBatch(batchSize, batchId);
await loadUnassigned();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to generate batch';
} finally {
generating = false;
}
}
async function handlePrintSheet() {
generating = true;
error = null;
try {
const ids = unassigned.map((entry) => entry.id);
if (ids.length === 0) {
error = 'No unassigned IDs to print. Generate a batch first.';
generating = false;
return;
}
const blob = await generateLabelSheetPDF(ids);
const timestamp = new Date().toISOString().slice(0, 10);
downloadBlob(blob, `solidhaus-labels-${timestamp}.pdf`);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to generate PDF';
} finally {
generating = false;
}
}
</script> </script>
<div class="p-4"> <div class="p-4 space-y-6">
<h1 class="text-2xl font-bold mb-4">Labels</h1> <h1 class="text-2xl font-bold">Labels</h1>
<p class="text-slate-400">Generate and print barcode label sheets.</p>
{#if error}
<div class="bg-red-500/20 text-red-400 px-4 py-2 rounded-lg text-sm">
{error}
</div>
{/if}
<!-- Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-slate-800 rounded-lg p-3">
<div class="text-2xl font-bold text-blue-400">{unassigned.length}</div>
<div class="text-xs text-slate-400">Available IDs</div>
</div>
<div class="bg-slate-800 rounded-lg p-3">
<div class="text-2xl font-bold text-emerald-400">{inventory.items.length}</div>
<div class="text-xs text-slate-400">Assigned Items</div>
</div>
</div>
<!-- Generate Batch -->
<div class="bg-slate-800 rounded-lg p-4 space-y-3">
<h2 class="font-medium text-white">Generate ID Batch</h2>
<p class="text-xs text-slate-400">
Pre-generate IDs for label printing. IDs can be assigned to items later when scanned.
</p>
<div class="flex gap-3 items-end">
<div class="flex-1">
<label for="batchSize" class="block text-xs text-slate-400 mb-1">Batch Size</label>
<select
id="batchSize"
bind:value={batchSize}
class="w-full bg-slate-700 border border-slate-600 rounded px-3 py-2 text-white text-sm"
>
<option value={10}>10 IDs</option>
<option value={25}>25 IDs</option>
<option value={50}>50 IDs (1 sheet)</option>
<option value={100}>100 IDs (2 sheets)</option>
</select>
</div>
<button
onclick={handleGenerateBatch}
disabled={generating}
class="px-4 py-2 bg-blue-500 text-white rounded-lg font-medium
hover:bg-blue-600 disabled:opacity-50 transition-colors"
>
Generate
</button>
</div>
</div>
<!-- Print Labels -->
<div class="bg-slate-800 rounded-lg p-4 space-y-3">
<h2 class="font-medium text-white">Print Label Sheet</h2>
<p class="text-xs text-slate-400">
Generate a PDF with QR code stickers for all unassigned IDs ({unassigned.length} labels).
</p>
<button
onclick={handlePrintSheet}
disabled={generating || unassigned.length === 0}
class="w-full py-3 bg-emerald-500 text-white rounded-lg font-medium
hover:bg-emerald-600 disabled:opacity-50 transition-colors"
>
{generating ? 'Generating...' : `Download PDF (${unassigned.length} labels)`}
</button>
</div>
<!-- Unassigned IDs Preview -->
{#if unassigned.length > 0}
<div class="bg-slate-800 rounded-lg p-4">
<h3 class="text-sm font-medium text-slate-400 mb-2">Unassigned IDs</h3>
<div class="grid grid-cols-4 gap-1 max-h-40 overflow-y-auto">
{#each unassigned as entry}
<span class="font-mono text-xs text-slate-500 text-center py-1">{entry.id}</span>
{/each}
</div>
</div>
{/if}
</div> </div>

View file

@ -1,7 +1,54 @@
<script lang="ts"> <script lang="ts">
import LocationTree from '$lib/components/LocationTree.svelte';
import ItemCard from '$lib/components/ItemCard.svelte';
import { inventory } from '$lib/stores/inventory.svelte';
import { goto } from '$app/navigation';
import type { Location } from '$lib/types';
let selectedLocation = $state<Location | null>(null);
const itemsAtLocation = $derived(
selectedLocation ? inventory.getItemsByLocation(selectedLocation.id) : []
);
function handleSelect(location: Location) {
selectedLocation = location;
}
</script> </script>
<div class="p-4"> <div class="p-4 space-y-4">
<h1 class="text-2xl font-bold mb-4">Places</h1> <h1 class="text-2xl font-bold">Places</h1>
<p class="text-slate-400">Manage your locations and rooms.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Location Tree -->
<div class="bg-slate-800 rounded-lg p-3">
<LocationTree onselect={handleSelect} selectedId={selectedLocation?.id ?? null} />
</div>
<!-- Items at selected location -->
{#if selectedLocation}
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="font-medium text-white">{selectedLocation.name}</h2>
<span class="text-xs text-slate-500">{itemsAtLocation.length} items</span>
</div>
{#if selectedLocation.description}
<p class="text-xs text-slate-400">{selectedLocation.description}</p>
{/if}
<div class="space-y-2">
{#each itemsAtLocation as item}
<ItemCard {item} onclick={() => goto(`/items/${item.shortId}`)} />
{:else}
<p class="text-sm text-slate-500 text-center py-4">No items here</p>
{/each}
</div>
</div>
{:else}
<div class="flex items-center justify-center text-sm text-slate-500 py-8">
Select a location to view items
</div>
{/if}
</div>
</div> </div>

View file

@ -1,7 +1,192 @@
<script lang="ts"> <script lang="ts">
import Scanner from '$lib/components/Scanner.svelte';
import ItemCard from '$lib/components/ItemCard.svelte';
import LocationPicker from '$lib/components/LocationPicker.svelte';
import { inventory } from '$lib/stores/inventory.svelte';
import { goto } from '$app/navigation';
import type { ScanResult } from '$lib/scanning/detector';
import type { Item, CheckOutReason } from '$lib/types';
let scannedItem = $state<Item | null>(null);
let scanError = $state<string | null>(null);
let showCheckOut = $state(false);
let checkOutReason = $state<CheckOutReason>('in-use');
let checkOutTo = $state('');
let checkOutNote = $state('');
let checkInLocation = $state('');
function handleScan(result: ScanResult) {
scanError = null;
const item = inventory.items.find((i) => i.shortId === result.itemId);
if (item) {
scannedItem = item;
} else {
scanError = `Item ${result.itemId} not found. Create it?`;
}
}
async function handleCheckIn() {
if (!scannedItem || !checkInLocation) return;
await inventory.checkIn(scannedItem.shortId, checkInLocation);
await inventory.recordSighting(scannedItem.shortId, checkInLocation, 'scan');
scannedItem = inventory.items.find((i) => i.shortId === scannedItem!.shortId) ?? null;
}
async function handleCheckOut() {
if (!scannedItem) return;
const fromLocation = scannedItem.lastSeenAt ?? '';
await inventory.checkOut(
scannedItem.shortId,
checkOutReason,
fromLocation,
checkOutTo || null,
checkOutNote
);
scannedItem = inventory.items.find((i) => i.shortId === scannedItem!.shortId) ?? null;
showCheckOut = false;
}
function reset() {
scannedItem = null;
scanError = null;
showCheckOut = false;
checkOutReason = 'in-use';
checkOutTo = '';
checkOutNote = '';
checkInLocation = '';
}
</script> </script>
<div class="p-4"> <div class="p-4 space-y-4">
<h1 class="text-2xl font-bold mb-4">Scan</h1> <h1 class="text-2xl font-bold">Scan</h1>
<p class="text-slate-400">Scan a barcode to look up or check in/out an item.</p>
{#if !scannedItem}
<Scanner onscan={handleScan} />
{#if scanError}
<div class="bg-slate-800 rounded-lg p-4 space-y-3">
<p class="text-amber-400 text-sm">{scanError}</p>
<a
href="/items/new"
class="block text-center py-2 bg-blue-500 text-white rounded-lg font-medium
hover:bg-blue-600 transition-colors"
>
Create New Item
</a>
</div>
{/if}
{:else}
<!-- Scanned Item Result -->
<div class="space-y-4">
<ItemCard item={scannedItem} onclick={() => goto(`/items/${scannedItem!.shortId}`)} />
{#if scannedItem.custodyState === 'checked-in'}
<!-- Check Out Flow -->
{#if !showCheckOut}
<div class="flex gap-3">
<button
onclick={() => (showCheckOut = true)}
class="flex-1 py-3 bg-amber-500 text-white rounded-lg font-medium
hover:bg-amber-600 transition-colors"
>
Check Out
</button>
<button
onclick={reset}
class="py-3 px-6 border border-slate-600 text-slate-300 rounded-lg
hover:bg-slate-700 transition-colors"
>
Done
</button>
</div>
{:else}
<div class="bg-slate-800 rounded-lg p-4 space-y-3">
<h3 class="font-medium text-white">Check Out</h3>
<div>
<label for="reason" class="block text-xs text-slate-400 mb-1">Reason</label>
<select
id="reason"
bind:value={checkOutReason}
class="w-full bg-slate-700 border border-slate-600 rounded px-3 py-2 text-white text-sm"
>
<option value="in-use">In Use</option>
<option value="in-transit">In Transit</option>
<option value="lent">Lent</option>
<option value="in-repair">In Repair</option>
<option value="temporary">Temporary</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Destination</label>
<LocationPicker bind:selected={checkOutTo} />
</div>
<div>
<label for="note" class="block text-xs text-slate-400 mb-1">Note</label>
<input
id="note"
type="text"
bind:value={checkOutNote}
class="w-full bg-slate-700 border border-slate-600 rounded px-3 py-2 text-white text-sm"
placeholder="Optional note"
/>
</div>
<div class="flex gap-2">
<button
onclick={handleCheckOut}
class="flex-1 py-2 bg-amber-500 text-white rounded-lg font-medium
hover:bg-amber-600 transition-colors"
>
Confirm Check Out
</button>
<button
onclick={() => (showCheckOut = false)}
class="py-2 px-4 border border-slate-600 text-slate-300 rounded-lg
hover:bg-slate-700 transition-colors"
>
Cancel
</button>
</div>
</div>
{/if}
{:else}
<!-- Check In Flow -->
<div class="bg-slate-800 rounded-lg p-4 space-y-3">
<h3 class="font-medium text-white">Check In</h3>
<p class="text-xs text-slate-400">
Currently {scannedItem.checkedOutReason ?? 'out'}
{#if scannedItem.checkedOutFrom}
from {inventory.locations.find((l) => l.id === scannedItem!.checkedOutFrom)?.name ?? scannedItem.checkedOutFrom}
{/if}
</p>
<div>
<label class="block text-xs text-slate-400 mb-1">Return to</label>
<LocationPicker bind:selected={checkInLocation} />
</div>
<div class="flex gap-2">
<button
onclick={handleCheckIn}
disabled={!checkInLocation}
class="flex-1 py-2 bg-emerald-500 text-white rounded-lg font-medium
hover:bg-emerald-600 disabled:opacity-50 transition-colors"
>
Check In
</button>
<button
onclick={reset}
class="py-2 px-4 border border-slate-600 text-slate-300 rounded-lg
hover:bg-slate-700 transition-colors"
>
Cancel
</button>
</div>
</div>
{/if}
</div>
{/if}
</div> </div>

View file

@ -1,7 +1,42 @@
<script lang="ts"> <script lang="ts">
import { inventory } from '$lib/stores/inventory.svelte';
const itemCount = $derived(inventory.items.length);
const locationCount = $derived(inventory.locations.length);
</script> </script>
<div class="p-4"> <div class="p-4 space-y-6">
<h1 class="text-2xl font-bold mb-4">Settings</h1> <h1 class="text-2xl font-bold">Settings</h1>
<p class="text-slate-400">App configuration and sync settings.</p>
<!-- Database Stats -->
<div class="bg-slate-800 rounded-lg p-4 space-y-2">
<h2 class="font-medium text-white">Database</h2>
<div class="flex justify-between text-sm">
<span class="text-slate-400">Items</span>
<span class="text-white">{itemCount}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-slate-400">Locations</span>
<span class="text-white">{locationCount}</span>
</div>
</div>
<!-- Sync (placeholder for Phase 2/3) -->
<div class="bg-slate-800 rounded-lg p-4 space-y-2">
<h2 class="font-medium text-white">Sync</h2>
<p class="text-xs text-slate-400">
Automerge CRDT sync and Solid Pod integration will be available in a future update.
</p>
</div>
<!-- About -->
<div class="bg-slate-800 rounded-lg p-4 space-y-2">
<h2 class="font-medium text-white">About</h2>
<p class="text-xs text-slate-400">
SolidHaus v0.1.0 &mdash; Local-first household inventory
</p>
<p class="text-xs text-slate-500">
Data is stored locally in IndexedDB. No data leaves your device.
</p>
</div>
</div> </div>