diff --git a/frontend/apps/cli/src/components/inventory/StockBatchEntryScreen.tsx b/frontend/apps/cli/src/components/inventory/StockBatchEntryScreen.tsx index c3ae9fd..a652db0 100644 --- a/frontend/apps/cli/src/components/inventory/StockBatchEntryScreen.tsx +++ b/frontend/apps/cli/src/components/inventory/StockBatchEntryScreen.tsx @@ -1,49 +1,61 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; import { useNavigation } from '../../state/navigation-context.js'; -import { FormInput } from '../shared/FormInput.js'; +import { useStocks } from '../../hooks/useStocks.js'; +import { useStockNameLookup } from '../../hooks/useStockNameLookup.js'; +import { StockPicker } from '../shared/StockPicker.js'; +import { LoadingSpinner } from '../shared/LoadingSpinner.js'; export function StockBatchEntryScreen() { const { navigate, back } = useNavigation(); - const [stockId, setStockId] = useState(''); - const [error, setError] = useState(null); + const { stocks, loading, fetchStocks } = useStocks(); + const { articleName, locationName } = useStockNameLookup(); + const [query, setQuery] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [selectedLabel, setSelectedLabel] = useState(undefined); - const handleChange = (value: string) => { - setStockId(value); - if (error) setError(null); - }; + useEffect(() => { + void fetchStocks(); + }, [fetchStocks]); useInput((_input, key) => { - if (key.escape || key.backspace) back(); + if (key.escape) back(); }); - const handleSubmit = () => { - if (!stockId.trim()) { - setError('Bestand-ID ist erforderlich.'); - return; - } - navigate('stock-add-batch', { stockId: stockId.trim() }); - }; - return ( Charge einbuchen - Geben Sie die Bestand-ID ein, um eine neue Charge einzubuchen. + Suchen Sie den Bestand, um eine neue Charge einzubuchen. - - - + {loading && } + + {!loading && ( + + { + setQuery(q); + setSelectedId(null); + setSelectedLabel(undefined); + }} + onSelect={(stock) => { + setSelectedId(stock.id); + setSelectedLabel(`${articleName(stock.articleId)} | ${locationName(stock.storageLocationId)}`); + setQuery(''); + navigate('stock-add-batch', { stockId: stock.id }); + }} + focus={!selectedId} + selectedLabel={selectedLabel} + articleName={articleName} + locationName={locationName} + /> + + )} - Enter Weiter · Escape Zurück + ↑↓ nav · Enter Auswählen · Escape Zurück diff --git a/frontend/apps/cli/src/components/inventory/StockDetailScreen.tsx b/frontend/apps/cli/src/components/inventory/StockDetailScreen.tsx index 4c5d701..01bda2b 100644 --- a/frontend/apps/cli/src/components/inventory/StockDetailScreen.tsx +++ b/frontend/apps/cli/src/components/inventory/StockDetailScreen.tsx @@ -3,11 +3,12 @@ import { Box, Text, useInput } from 'ink'; import TextInput from 'ink-text-input'; import { useNavigation } from '../../state/navigation-context.js'; import { useStocks } from '../../hooks/useStocks.js'; +import { useStockNameLookup } from '../../hooks/useStockNameLookup.js'; import { LoadingSpinner } from '../shared/LoadingSpinner.js'; import { ErrorDisplay } from '../shared/ErrorDisplay.js'; import { SuccessDisplay } from '../shared/SuccessDisplay.js'; import { STOCK_BATCH_STATUS_LABELS, REFERENCE_TYPE_LABELS, RESERVATION_PRIORITY_LABELS } from '@effigenix/api-client'; -import type { StockBatchStatus, ReferenceType, ReservationPriority } from '@effigenix/api-client'; +import type { StockBatchStatus, ReferenceType, ReservationPriority, ReservationDTO, StockBatchDTO } from '@effigenix/api-client'; type Mode = 'view' | 'menu' | 'batch-actions' | 'block-reason' | 'remove-amount' | 'remove-unit' | 'reservation-actions' | 'confirm-release-reservation'; @@ -21,6 +22,7 @@ const BATCH_STATUS_COLORS: Record = { export function StockDetailScreen() { const { params, back, navigate } = useNavigation(); const { stock, loading, error, fetchStock, blockBatch, unblockBatch, removeBatch, releaseReservation, clearError } = useStocks(); + const { articleName, locationName } = useStockNameLookup(); const [mode, setMode] = useState('view'); const [menuIndex, setMenuIndex] = useState(0); const [batchIndex, setBatchIndex] = useState(0); @@ -37,9 +39,9 @@ export function StockDetailScreen() { if (stockId) void fetchStock(stockId); }, [fetchStock, stockId]); - const batches = stock?.batches ?? []; + const batches: StockBatchDTO[] = stock?.batches ?? []; const selectedBatch = batches[batchIndex]; - const reservations = stock?.reservations ?? []; + const reservations: ReservationDTO[] = (stock as { reservations?: ReservationDTO[] })?.reservations ?? []; const selectedReservation = reservations[reservationIndex]; const getBatchActions = () => { @@ -221,8 +223,8 @@ export function StockDetailScreen() { {success && setSuccess(null)} />} - Artikel: {stock.articleId} - Lagerort: {stock.storageLocationId} + Artikel: {articleName(stock.articleId)} + Lagerort: {locationName(stock.storageLocationId)} Gesamtmenge: {stock.totalQuantity}{stock.quantityUnit ? ` ${stock.quantityUnit}` : ''} Verfügbar: {stock.availableQuantity}{stock.quantityUnit ? ` ${stock.quantityUnit}` : ''} {stock.minimumLevel && ( diff --git a/frontend/apps/cli/src/components/inventory/StockListScreen.tsx b/frontend/apps/cli/src/components/inventory/StockListScreen.tsx index d494805..e003a18 100644 --- a/frontend/apps/cli/src/components/inventory/StockListScreen.tsx +++ b/frontend/apps/cli/src/components/inventory/StockListScreen.tsx @@ -2,30 +2,83 @@ import React, { useEffect, useState } from 'react'; import { Box, Text, useInput } from 'ink'; import { useNavigation } from '../../state/navigation-context.js'; import { useStocks } from '../../hooks/useStocks.js'; +import { useStockNameLookup } from '../../hooks/useStockNameLookup.js'; import { LoadingSpinner } from '../shared/LoadingSpinner.js'; import { ErrorDisplay } from '../shared/ErrorDisplay.js'; export function StockListScreen() { const { navigate, back } = useNavigation(); const { stocks, loading, error, fetchStocks, clearError } = useStocks(); + const { articleName, locationName } = useStockNameLookup(); const [selectedIndex, setSelectedIndex] = useState(0); + const [searchMode, setSearchMode] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); useEffect(() => { void fetchStocks(); }, [fetchStocks]); + const filtered = React.useMemo(() => { + if (!searchQuery) return stocks; + const q = searchQuery.toLowerCase(); + return stocks.filter( + (s) => + articleName(s.articleId).toLowerCase().includes(q) || + locationName(s.storageLocationId).toLowerCase().includes(q) || + s.articleId.toLowerCase().includes(q) || + s.storageLocationId.toLowerCase().includes(q), + ); + }, [stocks, searchQuery, articleName, locationName]); + useInput((input, key) => { if (loading) return; - if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1)); - if (key.downArrow) setSelectedIndex((i) => Math.min(stocks.length - 1, i + 1)); + if (searchMode) { + if (key.escape) { + setSearchMode(false); + setSearchQuery(''); + setSelectedIndex(0); + return; + } + if (key.return && filtered.length > 0) { + const stock = filtered[selectedIndex]; + if (stock) navigate('stock-detail', { stockId: stock.id }); + return; + } + if (key.upArrow) { + setSelectedIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1)); + return; + } + if (key.backspace || key.delete) { + setSearchQuery((q) => q.slice(0, -1)); + setSelectedIndex(0); + return; + } + if (key.tab || key.ctrl || key.meta) return; + if (input && !key.upArrow && !key.downArrow) { + setSearchQuery((q) => q + input); + setSelectedIndex(0); + } + return; + } - if (key.return && stocks.length > 0) { - const stock = stocks[selectedIndex]; + if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1)); + if (key.downArrow) setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1)); + + if (key.return && filtered.length > 0) { + const stock = filtered[selectedIndex]; if (stock) navigate('stock-detail', { stockId: stock.id }); } if (input === 'n') navigate('stock-create'); if (input === 'r') void fetchStocks(); + if (input === 's' || input === '/') { + setSearchMode(true); + setSelectedIndex(0); + } if (key.backspace || key.escape) back(); }); @@ -33,27 +86,35 @@ export function StockListScreen() { Bestände - ({stocks.length}) + ({filtered.length}{searchQuery ? ` / ${stocks.length}` : ''}) + {searchMode && ( + + Suche: + {searchQuery || '▌'} + (Escape zum Beenden) + + )} + {loading && } {error && !loading && } {!loading && !error && ( - {' Artikel'.padEnd(20)} - {'Lagerort'.padEnd(20)} + {' Artikel'.padEnd(24)} + {'Lagerort'.padEnd(22)} {'Gesamt'.padEnd(14)} {'Verfügbar'.padEnd(14)} Chargen - {stocks.length === 0 && ( + {filtered.length === 0 && ( - Keine Bestände gefunden. + {searchQuery ? 'Keine Treffer.' : 'Keine Bestände gefunden.'} )} - {stocks.map((stock, index) => { + {filtered.map((stock, index) => { const isSelected = index === selectedIndex; const textColor = isSelected ? 'cyan' : 'white'; const totalStr = `${stock.totalQuantity}${stock.quantityUnit ? ` ${stock.quantityUnit}` : ''}`; @@ -62,8 +123,8 @@ export function StockListScreen() { return ( {isSelected ? '▶ ' : ' '} - {stock.articleId.substring(0, 16).padEnd(17)} - {stock.storageLocationId.substring(0, 18).padEnd(20)} + {articleName(stock.articleId).substring(0, 20).padEnd(21)} + {locationName(stock.storageLocationId).substring(0, 20).padEnd(22)} {totalStr.substring(0, 12).padEnd(14)} {availStr.substring(0, 12).padEnd(14)} {String(batchCount)} @@ -75,7 +136,7 @@ export function StockListScreen() { - ↑↓ nav · Enter Details · [n] Neu · [r] Aktualisieren · Backspace Zurück + ↑↓ nav · Enter Details · [n] Neu · [r] Aktualisieren · [s] Suche · Backspace Zurück diff --git a/frontend/apps/cli/src/components/shared/StockPicker.tsx b/frontend/apps/cli/src/components/shared/StockPicker.tsx new file mode 100644 index 0000000..33279a4 --- /dev/null +++ b/frontend/apps/cli/src/components/shared/StockPicker.tsx @@ -0,0 +1,122 @@ +import { useState, useMemo } from 'react'; +import { Box, Text, useInput } from 'ink'; +import type { StockDTO } from '@effigenix/api-client'; + +interface StockPickerProps { + stocks: StockDTO[]; + query: string; + onQueryChange: (q: string) => void; + onSelect: (stock: StockDTO) => void; + focus: boolean; + selectedLabel?: string | undefined; + maxVisible?: number; + articleName: (id: string) => string; + locationName: (id: string) => string; +} + +export function StockPicker({ + stocks, + query, + onQueryChange, + onSelect, + focus, + selectedLabel, + maxVisible = 5, + articleName, + locationName, +}: StockPickerProps) { + const [cursor, setCursor] = useState(0); + + const filtered = useMemo(() => { + if (!query) return []; + const q = query.toLowerCase(); + return stocks.filter( + (s) => + articleName(s.articleId).toLowerCase().includes(q) || + locationName(s.storageLocationId).toLowerCase().includes(q), + ).slice(0, maxVisible); + }, [stocks, query, maxVisible, articleName, locationName]); + + useInput((input, key) => { + if (!focus) return; + + if (key.upArrow) { + setCursor((c) => Math.max(0, c - 1)); + return; + } + if (key.downArrow) { + setCursor((c) => Math.min(filtered.length - 1, c + 1)); + return; + } + if (key.return && filtered.length > 0) { + const selected = filtered[cursor]; + if (selected) onSelect(selected); + return; + } + if (key.backspace || key.delete) { + onQueryChange(query.slice(0, -1)); + setCursor(0); + return; + } + + if (key.tab || key.escape || key.ctrl || key.meta) return; + + if (input && !key.upArrow && !key.downArrow) { + onQueryChange(query + input); + setCursor(0); + } + }, { isActive: focus }); + + if (!focus && selectedLabel) { + return ( + + Bestand * + + + ✓ {selectedLabel} + + + ); + } + + if (focus && selectedLabel && !query) { + return ( + + Bestand * (tippen zum Ändern) + + + ✓ {selectedLabel} + + + ); + } + + return ( + + Bestand * (Suche) + + + {query || (focus ? '▌' : '')} + + {focus && filtered.length > 0 && ( + + {filtered.map((s, i) => { + const availStr = `${s.availableQuantity}${s.quantityUnit ? ` ${s.quantityUnit}` : ''}`; + return ( + + + {i === cursor ? '▶ ' : ' '}{articleName(s.articleId)} | {locationName(s.storageLocationId)} | {availStr} + + + ); + })} + + )} + {focus && query && filtered.length === 0 && ( + + Keine Bestände gefunden. + + )} + + ); +} diff --git a/frontend/apps/cli/src/hooks/useStockNameLookup.ts b/frontend/apps/cli/src/hooks/useStockNameLookup.ts new file mode 100644 index 0000000..2c7e13c --- /dev/null +++ b/frontend/apps/cli/src/hooks/useStockNameLookup.ts @@ -0,0 +1,37 @@ +import { useEffect, useMemo, useCallback } from 'react'; +import { useArticles } from './useArticles.js'; +import { useStorageLocations } from './useStorageLocations.js'; + +export function useStockNameLookup() { + const { articles, fetchArticles } = useArticles(); + const { storageLocations, fetchStorageLocations } = useStorageLocations(); + + useEffect(() => { + void fetchArticles(); + void fetchStorageLocations(); + }, [fetchArticles, fetchStorageLocations]); + + const articleNames = useMemo(() => { + const map = new Map(); + for (const a of articles) map.set(a.id, a.name); + return map; + }, [articles]); + + const locationNames = useMemo(() => { + const map = new Map(); + for (const l of storageLocations) map.set(l.id, l.name); + return map; + }, [storageLocations]); + + const articleName = useCallback( + (id: string) => articleNames.get(id) ?? id.substring(0, 8) + '…', + [articleNames], + ); + + const locationName = useCallback( + (id: string) => locationNames.get(id) ?? id.substring(0, 8) + '…', + [locationNames], + ); + + return { articleName, locationName }; +}