kammer/src/lib/stores/inventory.svelte.ts
Christopher Mühl 9f4c743eb8 feat: inventory store with Svelte 5 runes and full test suite
Reactive store with $state/$derived for items, locations, checked-out
items, overdue items, and low-stock items. Supports CRUD, check-in/out,
sighting recording, and location tree queries. 12 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:34:05 +01:00

128 lines
3.6 KiB
TypeScript

import { getDB } from '$lib/data/db';
import { getAllItems, createItem as dbCreateItem, updateItem as dbUpdateItem, removeItem as dbRemoveItem } from '$lib/data/items';
import { getAllLocations, seedDefaultLocations } from '$lib/data/locations';
import { createSighting } from '$lib/data/sightings';
import { generateItemId, generateSightingId } from '$lib/utils/id';
import { getConfidence } from '$lib/utils/confidence';
import { buildCheckOutUpdate, buildCheckInUpdate } from '$lib/utils/custody';
import type { Item, Location, Sighting, CheckOutReason } from '$lib/types';
class InventoryStore {
items = $state<Item[]>([]);
locations = $state<Location[]>([]);
loading = $state(true);
checkedOutItems = $derived(
this.items.filter((i) => i.custodyState === 'checked-out')
);
overdueItems = $derived(
this.items.filter(
(i) =>
i.custodyState === 'checked-out' &&
i.checkedOutSince &&
Date.now() - new Date(i.checkedOutSince).getTime() > 7 * 86400000
)
);
lowStockItems = $derived(
this.items.filter(
(i) =>
i.currentQuantity != null &&
i.lowThreshold != null &&
i.currentQuantity <= i.lowThreshold
)
);
async loadAll() {
await seedDefaultLocations();
this.items = await getAllItems();
this.locations = await getAllLocations();
this.loading = false;
}
async createItem(data: Omit<Item, 'shortId' | 'createdAt' | 'updatedAt' | 'barcodeUri' | 'barcodeFormat'>): Promise<Item> {
const now = new Date().toISOString();
const shortId = generateItemId();
const item: Item = {
...data,
shortId,
barcodeFormat: 'qr',
barcodeUri: `https://haus.toph.so/${shortId}`,
createdAt: now,
updatedAt: now,
};
await dbCreateItem(item);
this.items = [...this.items, item];
return item;
}
async updateItem(shortId: string, updates: Partial<Item>): Promise<Item | undefined> {
const updated = await dbUpdateItem(shortId, updates);
if (updated) {
this.items = this.items.map((i) => (i.shortId === shortId ? updated : i));
}
return updated;
}
async removeItem(shortId: string): Promise<void> {
await dbRemoveItem(shortId);
this.items = this.items.filter((i) => i.shortId !== shortId);
}
async checkOut(
itemId: string,
reason: CheckOutReason,
fromLocationId: string,
toLocationId: string | null = null,
note: string = ''
): Promise<void> {
const updates = buildCheckOutUpdate(reason, fromLocationId, toLocationId, note);
await this.updateItem(itemId, updates);
}
async checkIn(itemId: string, returnToLocationId: string): Promise<void> {
const updates = buildCheckInUpdate(returnToLocationId);
await this.updateItem(itemId, updates);
}
async recordSighting(
itemId: string,
locationId: string,
type: 'scan' | 'manual' | 'camera-detect' | 'audit-verify' = 'scan'
): Promise<void> {
const now = new Date().toISOString();
const sighting: Sighting = {
id: generateSightingId(),
itemId,
locationId,
timestamp: now,
sightingType: type,
confidence: 'confirmed',
notes: '',
createdBy: null,
};
await createSighting(sighting);
await this.updateItem(itemId, {
lastSeenAt: locationId,
lastSeenTimestamp: now,
locationConfidence: 'confirmed',
});
}
getItemsByLocation(locationId: string): Item[] {
return this.items.filter((i) => i.lastSeenAt === locationId);
}
getLocationTree(): Map<string | null, Location[]> {
const tree = new Map<string | null, Location[]>();
for (const loc of this.locations) {
const children = tree.get(loc.parentId) ?? [];
children.push(loc);
tree.set(loc.parentId, children);
}
return tree;
}
}
export const inventory = new InventoryStore();