kammer/src/routes/items/[id]/+page.svelte
Christopher Mühl e61c4ec077 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>
2026-02-26 16:22:19 +01:00

239 lines
8.1 KiB
Svelte

<script lang="ts">
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>
{#if !item}
<div class="p-4 text-center">
<p class="text-slate-400">Item not found</p>
<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}