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:
parent
9f4c743eb8
commit
1ef4661d9a
8 changed files with 693 additions and 0 deletions
19
src/lib/components/ConfidenceBadge.svelte
Normal file
19
src/lib/components/ConfidenceBadge.svelte
Normal 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>
|
||||
24
src/lib/components/CustodyBadge.svelte
Normal file
24
src/lib/components/CustodyBadge.svelte
Normal 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}
|
||||
67
src/lib/components/ItemCard.svelte
Normal file
67
src/lib/components/ItemCard.svelte
Normal 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>
|
||||
97
src/lib/components/ItemCard.test.ts
Normal file
97
src/lib/components/ItemCard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
292
src/lib/components/ItemForm.svelte
Normal file
292
src/lib/components/ItemForm.svelte
Normal 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>
|
||||
101
src/lib/components/LocationPicker.svelte
Normal file
101
src/lib/components/LocationPicker.svelte
Normal 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>
|
||||
92
src/lib/components/LocationTree.svelte
Normal file
92
src/lib/components/LocationTree.svelte
Normal 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' : ''}"
|
||||
>▶</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>
|
||||
|
|
@ -9,6 +9,7 @@ export default defineConfig({
|
|||
$lib: path.resolve('./src/lib'),
|
||||
$app: path.resolve('./src/test/mocks'),
|
||||
},
|
||||
conditions: ['browser'],
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue