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:
parent
0b382b9c5e
commit
1ffdb937ce
11 changed files with 924 additions and 28 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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' : ''}"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<main class="flex-1 overflow-y-auto pb-16">
|
{#if inventory.loading}
|
||||||
{@render children()}
|
<div class="flex-1 flex items-center justify-center">
|
||||||
</main>
|
<div class="text-slate-400 animate-pulse">Loading...</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<main class="flex-1 overflow-y-auto pb-16">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
{/if}
|
||||||
<BottomNav currentPath={$page.url.pathname} />
|
<BottomNav currentPath={$page.url.pathname} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">← 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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 — 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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue