feat: UI components — ItemCard, ItemForm, LocationTree, LocationPicker

Includes ConfidenceBadge and CustodyBadge helper components.
Dark slate theme with confidence/custody color coding.
ItemCard test suite (6 tests). Fixed vitest browser resolve conditions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Mühl 2026-02-26 15:36:56 +01:00
parent 087862bd44
commit 248bcd94df
8 changed files with 693 additions and 0 deletions

View file

@ -0,0 +1,19 @@
<script lang="ts">
import type { LocationConfidence } from '$lib/types';
let { confidence }: { confidence: LocationConfidence } = $props();
const config = {
confirmed: { bg: 'bg-emerald-500/20', text: 'text-emerald-400', icon: '\u2713', label: 'Confirmed' },
likely: { bg: 'bg-amber-500/20', text: 'text-amber-400', icon: '\u25F7', label: 'Likely' },
assumed: { bg: 'bg-slate-500/20', text: 'text-slate-400', icon: '?', label: 'Assumed' },
unknown: { bg: 'bg-red-500/20', text: 'text-red-400', icon: '!', label: 'Unknown' },
};
const c = $derived(config[confidence]);
</script>
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium {c.bg} {c.text}">
<span>{c.icon}</span>
<span>{c.label}</span>
</span>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type { CustodyState, CheckOutReason } from '$lib/types';
let { state, reason = null }: { state: CustodyState; reason?: CheckOutReason } = $props();
const reasonLabels: Record<string, string> = {
'in-use': 'In Use',
'in-transit': 'In Transit',
'lent': 'Lent',
'in-repair': 'In Repair',
'temporary': 'Temporary',
'consumed': 'Consumed',
};
</script>
{#if state === 'checked-in'}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/20 text-emerald-400">
Home
</span>
{:else}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/20 text-amber-400">
{reason ? reasonLabels[reason] ?? 'Out' : 'Out'}
</span>
{/if}

View file

@ -0,0 +1,67 @@
<script lang="ts">
import type { Item } from '$lib/types';
import { getConfidence } from '$lib/utils/confidence';
import ConfidenceBadge from './ConfidenceBadge.svelte';
import CustodyBadge from './CustodyBadge.svelte';
import { inventory } from '$lib/stores/inventory.svelte';
let { item, onclick }: { item: Item; onclick?: () => void } = $props();
const confidence = $derived(getConfidence(item.lastSeenTimestamp));
const locationName = $derived(
inventory.locations.find((l) => l.id === item.lastSeenAt)?.name ?? 'Unknown'
);
const isConsumable = $derived(
item.itemType === 'consumable' || item.itemType === 'perishable'
);
const quantityPercent = $derived(
item.originalQuantity && item.currentQuantity != null
? Math.round((item.currentQuantity / item.originalQuantity) * 100)
: null
);
</script>
<button
class="w-full text-left bg-slate-800 rounded-lg p-4 hover:bg-slate-700 transition-colors"
onclick={onclick}
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<h3 class="font-medium text-white truncate">{item.name}</h3>
{#if item.category}
<p class="text-xs text-slate-400 mt-0.5">{item.category}</p>
{/if}
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<CustodyBadge state={item.custodyState} reason={item.checkedOutReason} />
<ConfidenceBadge {confidence} />
</div>
</div>
<div class="mt-2 flex items-center gap-2 text-xs text-slate-400">
<span>{locationName}</span>
<span class="font-mono text-slate-500 tracking-wider">{item.shortId}</span>
</div>
{#if isConsumable && quantityPercent != null}
<div class="mt-2">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-slate-400">
{item.currentQuantity}{item.quantityUnit ? ` ${item.quantityUnit}` : ''}
</span>
<span class="text-slate-500">{quantityPercent}%</span>
</div>
<div class="w-full bg-slate-700 rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all {quantityPercent <= 20
? 'bg-red-400'
: quantityPercent <= 50
? 'bg-amber-400'
: 'bg-emerald-400'}"
style="width: {quantityPercent}%"
></div>
</div>
</div>
{/if}
</button>

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ItemCard from './ItemCard.svelte';
import type { Item } from '$lib/types';
function makeItem(overrides: Partial<Item> = {}): Item {
return {
shortId: 'abc2345',
name: 'Test Item',
description: 'A test item',
category: 'electronics',
brand: 'TestBrand',
serialNumber: 'SN123',
color: 'black',
purchaseDate: null,
itemType: 'durable',
currentQuantity: null,
originalQuantity: null,
quantityUnit: null,
lowThreshold: null,
expiryDate: null,
barcodeFormat: 'qr',
barcodeUri: 'https://haus.toph.so/abc2345',
photoIds: [],
lastSeenAt: null,
lastSeenTimestamp: null,
lastUsedAt: null,
supposedToBeAt: null,
locationConfidence: 'unknown',
custodyState: 'checked-in',
checkedOutSince: null,
checkedOutReason: null,
checkedOutFrom: null,
checkedOutTo: null,
checkedOutNote: null,
storageTier: 'warm',
storageContainerId: null,
storageContainerLabel: null,
labelPrinted: false,
labelPrintedAt: null,
labelBatchId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: [],
createdBy: null,
...overrides,
};
}
describe('ItemCard', () => {
it('renders item name', () => {
render(ItemCard, { props: { item: makeItem({ name: 'My Widget' }) } });
expect(screen.getByText('My Widget')).toBeInTheDocument();
});
it('renders item category', () => {
render(ItemCard, { props: { item: makeItem({ category: 'tools' }) } });
expect(screen.getByText('tools')).toBeInTheDocument();
});
it('renders shortId', () => {
render(ItemCard, { props: { item: makeItem({ shortId: 'xyz9876' }) } });
expect(screen.getByText('xyz9876')).toBeInTheDocument();
});
it('shows Home badge for checked-in items', () => {
render(ItemCard, { props: { item: makeItem({ custodyState: 'checked-in' }) } });
expect(screen.getByText('Home')).toBeInTheDocument();
});
it('shows checkout reason for checked-out items', () => {
render(ItemCard, {
props: {
item: makeItem({
custodyState: 'checked-out',
checkedOutReason: 'lent',
}),
},
});
expect(screen.getByText('Lent')).toBeInTheDocument();
});
it('shows quantity bar for consumable items', () => {
const { container } = render(ItemCard, {
props: {
item: makeItem({
itemType: 'consumable',
currentQuantity: 30,
originalQuantity: 100,
quantityUnit: 'ml',
}),
},
});
expect(screen.getByText(/30 ml/)).toBeInTheDocument();
expect(screen.getByText('30%')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,292 @@
<script lang="ts">
import type { Item, ItemType, StorageTier } from '$lib/types';
import LocationPicker from './LocationPicker.svelte';
let {
item = null,
onsubmit,
oncancel,
}: {
item?: Partial<Item> | null;
onsubmit: (data: Partial<Item>) => void;
oncancel?: () => void;
} = $props();
let name = $state(item?.name ?? '');
let description = $state(item?.description ?? '');
let category = $state(item?.category ?? '');
let brand = $state(item?.brand ?? '');
let serialNumber = $state(item?.serialNumber ?? '');
let color = $state(item?.color ?? '');
let purchaseDate = $state(item?.purchaseDate ?? '');
let itemType = $state<ItemType>(item?.itemType ?? 'durable');
let storageTier = $state<StorageTier>(item?.storageTier ?? 'warm');
let supposedToBeAt = $state(item?.supposedToBeAt ?? '');
// Consumable/perishable fields
let currentQuantity = $state<number | null>(item?.currentQuantity ?? null);
let originalQuantity = $state<number | null>(item?.originalQuantity ?? null);
let quantityUnit = $state(item?.quantityUnit ?? '');
let lowThreshold = $state<number | null>(item?.lowThreshold ?? null);
let expiryDate = $state(item?.expiryDate ?? '');
const showQuantityFields = $derived(
itemType === 'consumable' || itemType === 'perishable'
);
const itemTypes: { value: ItemType; label: string }[] = [
{ value: 'durable', label: 'Durable' },
{ value: 'consumable', label: 'Consumable' },
{ value: 'disposable', label: 'Disposable' },
{ value: 'perishable', label: 'Perishable' },
{ value: 'media', label: 'Media' },
{ value: 'clothing', label: 'Clothing' },
{ value: 'document', label: 'Document' },
{ value: 'container', label: 'Container' },
];
const storageTiers: { value: StorageTier; label: string; desc: string }[] = [
{ value: 'hot', label: 'Hot', desc: 'Daily use' },
{ value: 'warm', label: 'Warm', desc: 'Regular use' },
{ value: 'cold', label: 'Cold', desc: 'Long-term storage' },
];
function handleSubmit() {
const data: Partial<Item> = {
name,
description,
category,
brand,
serialNumber,
color,
purchaseDate: purchaseDate || null,
itemType,
storageTier,
supposedToBeAt: supposedToBeAt || null,
currentQuantity: showQuantityFields ? currentQuantity : null,
originalQuantity: showQuantityFields ? originalQuantity : null,
quantityUnit: showQuantityFields ? quantityUnit || null : null,
lowThreshold: showQuantityFields ? lowThreshold : null,
expiryDate: showQuantityFields && expiryDate ? expiryDate : null,
};
onsubmit(data);
}
</script>
<form onsubmit|preventDefault={handleSubmit} class="space-y-4 p-4">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-slate-300 mb-1">Name *</label>
<input
id="name"
type="text"
bind:value={name}
required
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"
placeholder="Item name"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-slate-300 mb-1">Description</label>
<textarea
id="description"
bind:value={description}
rows="2"
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"
placeholder="Optional description"
></textarea>
</div>
<!-- Category & Brand -->
<div class="grid grid-cols-2 gap-3">
<div>
<label for="category" class="block text-sm font-medium text-slate-300 mb-1">Category</label>
<input
id="category"
type="text"
bind:value={category}
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"
placeholder="e.g. Electronics"
/>
</div>
<div>
<label for="brand" class="block text-sm font-medium text-slate-300 mb-1">Brand</label>
<input
id="brand"
type="text"
bind:value={brand}
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"
placeholder="e.g. Sony"
/>
</div>
</div>
<!-- Type -->
<div>
<label for="itemType" class="block text-sm font-medium text-slate-300 mb-1">Type</label>
<select
id="itemType"
bind:value={itemType}
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white
focus:outline-none focus:border-blue-400"
>
{#each itemTypes as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</div>
<!-- Quantity fields (consumable/perishable) -->
{#if showQuantityFields}
<div class="border border-slate-700 rounded-lg p-3 space-y-3">
<p class="text-xs font-medium text-slate-400 uppercase">Quantity Tracking</p>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="currentQty" class="block text-xs text-slate-400 mb-1">Current</label>
<input
id="currentQty"
type="number"
bind:value={currentQuantity}
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white
focus:outline-none focus:border-blue-400"
/>
</div>
<div>
<label for="originalQty" class="block text-xs text-slate-400 mb-1">Original</label>
<input
id="originalQty"
type="number"
bind:value={originalQuantity}
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white
focus:outline-none focus:border-blue-400"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="unit" class="block text-xs text-slate-400 mb-1">Unit</label>
<input
id="unit"
type="text"
bind:value={quantityUnit}
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white
focus:outline-none focus:border-blue-400"
placeholder="e.g. ml, pcs"
/>
</div>
<div>
<label for="threshold" class="block text-xs text-slate-400 mb-1">Low Threshold</label>
<input
id="threshold"
type="number"
bind:value={lowThreshold}
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white
focus:outline-none focus:border-blue-400"
/>
</div>
</div>
{#if itemType === 'perishable'}
<div>
<label for="expiry" class="block text-xs text-slate-400 mb-1">Expiry Date</label>
<input
id="expiry"
type="date"
bind:value={expiryDate}
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white
focus:outline-none focus:border-blue-400"
/>
</div>
{/if}
</div>
{/if}
<!-- Serial Number & Color -->
<div class="grid grid-cols-2 gap-3">
<div>
<label for="serial" class="block text-sm font-medium text-slate-300 mb-1">Serial #</label>
<input
id="serial"
type="text"
bind:value={serialNumber}
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"
/>
</div>
<div>
<label for="color" class="block text-sm font-medium text-slate-300 mb-1">Color</label>
<input
id="color"
type="text"
bind:value={color}
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"
/>
</div>
</div>
<!-- Purchase Date -->
<div>
<label for="purchaseDate" class="block text-sm font-medium text-slate-300 mb-1">Purchase Date</label>
<input
id="purchaseDate"
type="date"
bind:value={purchaseDate}
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-white
focus:outline-none focus:border-blue-400"
/>
</div>
<!-- Storage Tier -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">Storage Tier</label>
<div class="flex gap-2">
{#each storageTiers as tier}
<button
type="button"
onclick={() => (storageTier = tier.value)}
class="flex-1 py-2 px-3 rounded-lg border text-sm text-center transition-colors
{storageTier === tier.value
? 'border-blue-400 bg-blue-400/10 text-blue-400'
: 'border-slate-600 bg-slate-800 text-slate-400'}"
>
<div class="font-medium">{tier.label}</div>
<div class="text-xs opacity-70">{tier.desc}</div>
</button>
{/each}
</div>
</div>
<!-- Location -->
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Home Location</label>
<LocationPicker bind:selected={supposedToBeAt} />
</div>
<!-- Actions -->
<div class="flex gap-3 pt-2">
{#if oncancel}
<button
type="button"
onclick={oncancel}
class="flex-1 py-3 rounded-lg border border-slate-600 text-slate-300
hover:bg-slate-700 transition-colors"
>
Cancel
</button>
{/if}
<button
type="submit"
disabled={!name.trim()}
class="flex-1 py-3 rounded-lg bg-blue-500 text-white font-medium
hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{item ? 'Save Changes' : 'Create Item'}
</button>
</div>
</form>

View file

@ -0,0 +1,101 @@
<script lang="ts">
import type { Location } from '$lib/types';
import { inventory } from '$lib/stores/inventory.svelte';
let { selected = $bindable('') }: { selected: string } = $props();
let open = $state(false);
let search = $state('');
const tree = $derived(inventory.getLocationTree());
const flatLocations = $derived(
inventory.locations.filter(
(l) => !search || l.name.toLowerCase().includes(search.toLowerCase())
)
);
const selectedLocation = $derived(
inventory.locations.find((l) => l.id === selected)
);
function getDepth(loc: Location): number {
let depth = 0;
let current = loc;
while (current.parentId) {
depth++;
const parent = inventory.locations.find((l) => l.id === current.parentId);
if (!parent) break;
current = parent;
}
return depth;
}
function buildSortedList(): Location[] {
const result: Location[] = [];
function walk(parentId: string | null) {
const children = (tree.get(parentId) ?? []).sort((a, b) => a.sortOrder - b.sortOrder);
for (const child of children) {
result.push(child);
walk(child.id);
}
}
walk(null);
return result;
}
const sortedLocations = $derived(
search ? flatLocations : buildSortedList()
);
function select(loc: Location) {
selected = loc.id;
open = false;
search = '';
}
</script>
<div class="relative">
<button
type="button"
class="w-full bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-left
text-sm focus:outline-none focus:border-blue-400 flex items-center justify-between"
onclick={() => (open = !open)}
>
<span class={selectedLocation ? 'text-white' : 'text-slate-500'}>
{selectedLocation?.name ?? 'Select location...'}
</span>
<span class="text-slate-400 text-xs">{open ? '\u25B2' : '\u25BC'}</span>
</button>
{#if open}
<div class="absolute z-20 mt-1 w-full bg-slate-800 border border-slate-600 rounded-lg shadow-xl max-h-60 overflow-y-auto">
<div class="sticky top-0 bg-slate-800 p-2 border-b border-slate-700">
<input
type="text"
bind:value={search}
placeholder="Search locations..."
class="w-full bg-slate-700 border-none rounded px-2 py-1.5 text-sm text-white
placeholder-slate-500 focus:outline-none"
/>
</div>
{#each sortedLocations as loc}
{@const depth = getDepth(loc)}
<button
type="button"
class="w-full text-left px-3 py-2 text-sm hover:bg-slate-700 transition-colors
{selected === loc.id ? 'text-blue-400 bg-blue-500/10' : 'text-white'}"
style="padding-left: {12 + depth * 16}px"
onclick={() => select(loc)}
>
{loc.name}
</button>
{/each}
{#if sortedLocations.length === 0}
<p class="text-center text-sm text-slate-500 py-3">No locations found</p>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { Location } from '$lib/types';
import { inventory } from '$lib/stores/inventory.svelte';
let {
onselect,
selectedId = null,
}: {
onselect?: (location: Location) => void;
selectedId?: string | null;
} = $props();
let expanded = $state<Set<string>>(new Set(['home']));
const tree = $derived(inventory.getLocationTree());
const roots = $derived(tree.get(null) ?? []);
function toggle(id: string) {
const next = new Set(expanded);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
expanded = next;
}
function getItemCount(locationId: string): number {
return inventory.items.filter((i) => i.lastSeenAt === locationId).length;
}
function getChildren(parentId: string): Location[] {
return (tree.get(parentId) ?? []).sort((a, b) => a.sortOrder - b.sortOrder);
}
function hasChildren(id: string): boolean {
return (tree.get(id) ?? []).length > 0;
}
</script>
{#snippet locationNode(location: Location, depth: number)}
{@const children = getChildren(location.id)}
{@const itemCount = getItemCount(location.id)}
{@const isExpanded = expanded.has(location.id)}
{@const isSelected = selectedId === location.id}
<div class="select-none" style="padding-left: {depth * 16}px">
<button
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
min-h-[44px] transition-colors
{isSelected ? 'bg-blue-500/20 text-blue-400' : 'hover:bg-slate-700 text-white'}"
onclick={() => onselect?.(location)}
>
<!-- Expand/collapse toggle -->
{#if hasChildren(location.id)}
<button
class="w-5 h-5 flex items-center justify-center text-slate-400 hover:text-white shrink-0"
onclick|stopPropagation={() => toggle(location.id)}
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
<span class="text-xs transition-transform {isExpanded ? 'rotate-90' : ''}"
>&#9654;</span
>
</button>
{:else}
<span class="w-5 shrink-0"></span>
{/if}
<span class="flex-1 truncate text-sm">{location.name}</span>
{#if itemCount > 0}
<span class="text-xs text-slate-500 tabular-nums">{itemCount}</span>
{/if}
</button>
</div>
{#if isExpanded && children.length > 0}
{#each children as child}
{@render locationNode(child, depth + 1)}
{/each}
{/if}
{/snippet}
<div class="space-y-0.5">
{#each roots as root}
{@render locationNode(root, 0)}
{/each}
{#if roots.length === 0}
<p class="text-sm text-slate-500 text-center py-4">No locations configured</p>
{/if}
</div>

View file

@ -9,6 +9,7 @@ export default defineConfig({
$lib: path.resolve('./src/lib'),
$app: path.resolve('./src/test/mocks'),
},
conditions: ['browser'],
},
test: {
environment: 'jsdom',