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'),
|
$lib: path.resolve('./src/lib'),
|
||||||
$app: path.resolve('./src/test/mocks'),
|
$app: path.resolve('./src/test/mocks'),
|
||||||
},
|
},
|
||||||
|
conditions: ['browser'],
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue