- 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>
239 lines
8.1 KiB
Svelte
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">← 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}
|