1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:29:34 +01:00

feat(tui): Stock-Suche mit Namensanzeige für Bestände

StockPicker-Komponente für Bestandssuche nach Artikel-/Lagerort-Namen.
StockBatchEntryScreen nutzt StockPicker statt manueller UUID-Eingabe.
StockListScreen mit Suchfilter [s] und Namensanzeige statt IDs.
StockDetailScreen zeigt Artikel-/Lagerort-Namen im Header.
This commit is contained in:
Sebastian Frick 2026-02-24 01:21:02 +01:00
parent 376557925a
commit e25d4437d9
5 changed files with 281 additions and 47 deletions

View file

@ -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<string | null>(null);
const { stocks, loading, fetchStocks } = useStocks();
const { articleName, locationName } = useStockNameLookup();
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedLabel, setSelectedLabel] = useState<string | undefined>(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 (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Charge einbuchen</Text>
<Text color="gray">Geben Sie die Bestand-ID ein, um eine neue Charge einzubuchen.</Text>
<Text color="gray">Suchen Sie den Bestand, um eine neue Charge einzubuchen.</Text>
<Box flexDirection="column" width={60}>
<FormInput
label="Bestand-ID *"
value={stockId}
onChange={handleChange}
onSubmit={handleSubmit}
focus={true}
{...(error ? { error } : {})}
/>
</Box>
{loading && <LoadingSpinner label="Lade Bestände..." />}
{!loading && (
<Box flexDirection="column" width={80}>
<StockPicker
stocks={stocks}
query={query}
onQueryChange={(q) => {
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}
/>
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
Enter Weiter · Escape Zurück
nav · Enter Auswählen · Escape Zurück
</Text>
</Box>
</Box>

View file

@ -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<string, string> = {
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<Mode>('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 && <SuccessDisplay message={success} onDismiss={() => setSuccess(null)} />}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Box><Text color="gray">Artikel: </Text><Text>{stock.articleId}</Text></Box>
<Box><Text color="gray">Lagerort: </Text><Text>{stock.storageLocationId}</Text></Box>
<Box><Text color="gray">Artikel: </Text><Text>{articleName(stock.articleId)}</Text></Box>
<Box><Text color="gray">Lagerort: </Text><Text>{locationName(stock.storageLocationId)}</Text></Box>
<Box><Text color="gray">Gesamtmenge: </Text><Text>{stock.totalQuantity}{stock.quantityUnit ? ` ${stock.quantityUnit}` : ''}</Text></Box>
<Box><Text color="gray">Verfügbar: </Text><Text color="green">{stock.availableQuantity}{stock.quantityUnit ? ` ${stock.quantityUnit}` : ''}</Text></Box>
{stock.minimumLevel && (

View file

@ -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() {
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Bestände</Text>
<Text color="gray" dimColor>({stocks.length})</Text>
<Text color="gray" dimColor>({filtered.length}{searchQuery ? ` / ${stocks.length}` : ''})</Text>
</Box>
{searchMode && (
<Box>
<Text color="cyan">Suche: </Text>
<Text>{searchQuery || '▌'}</Text>
<Text color="gray" dimColor> (Escape zum Beenden)</Text>
</Box>
)}
{loading && <LoadingSpinner label="Lade Bestände..." />}
{error && !loading && <ErrorDisplay message={error} onDismiss={clearError} />}
{!loading && !error && (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Box paddingX={1}>
<Text color="gray" bold>{' Artikel'.padEnd(20)}</Text>
<Text color="gray" bold>{'Lagerort'.padEnd(20)}</Text>
<Text color="gray" bold>{' Artikel'.padEnd(24)}</Text>
<Text color="gray" bold>{'Lagerort'.padEnd(22)}</Text>
<Text color="gray" bold>{'Gesamt'.padEnd(14)}</Text>
<Text color="gray" bold>{'Verfügbar'.padEnd(14)}</Text>
<Text color="gray" bold>Chargen</Text>
</Box>
{stocks.length === 0 && (
{filtered.length === 0 && (
<Box paddingX={1} paddingY={1}>
<Text color="gray" dimColor>Keine Bestände gefunden.</Text>
<Text color="gray" dimColor>{searchQuery ? 'Keine Treffer.' : 'Keine Bestände gefunden.'}</Text>
</Box>
)}
{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 (
<Box key={stock.id} paddingX={1}>
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
<Text color={textColor}>{stock.articleId.substring(0, 16).padEnd(17)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{stock.storageLocationId.substring(0, 18).padEnd(20)}</Text>
<Text color={textColor}>{articleName(stock.articleId).substring(0, 20).padEnd(21)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{locationName(stock.storageLocationId).substring(0, 20).padEnd(22)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{totalStr.substring(0, 12).padEnd(14)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{availStr.substring(0, 12).padEnd(14)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{String(batchCount)}</Text>
@ -75,7 +136,7 @@ export function StockListScreen() {
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · [r] Aktualisieren · Backspace Zurück
nav · Enter Details · [n] Neu · [r] Aktualisieren · [s] Suche · Backspace Zurück
</Text>
</Box>
</Box>

View file

@ -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 (
<Box flexDirection="column">
<Text color="gray">Bestand *</Text>
<Box>
<Text color="gray"> </Text>
<Text color="green"> {selectedLabel}</Text>
</Box>
</Box>
);
}
if (focus && selectedLabel && !query) {
return (
<Box flexDirection="column">
<Text color="cyan">Bestand * (tippen zum Ändern)</Text>
<Box>
<Text color="gray"> </Text>
<Text color="green"> {selectedLabel}</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column">
<Text color={focus ? 'cyan' : 'gray'}>Bestand * (Suche)</Text>
<Box>
<Text color="gray"> </Text>
<Text>{query || (focus ? '▌' : '')}</Text>
</Box>
{focus && filtered.length > 0 && (
<Box flexDirection="column" paddingLeft={2}>
{filtered.map((s, i) => {
const availStr = `${s.availableQuantity}${s.quantityUnit ? ` ${s.quantityUnit}` : ''}`;
return (
<Box key={s.id}>
<Text color={i === cursor ? 'cyan' : 'white'}>
{i === cursor ? '▶ ' : ' '}{articleName(s.articleId)} | {locationName(s.storageLocationId)} | {availStr}
</Text>
</Box>
);
})}
</Box>
)}
{focus && query && filtered.length === 0 && (
<Box paddingLeft={2}>
<Text color="yellow">Keine Bestände gefunden.</Text>
</Box>
)}
</Box>
);
}

View file

@ -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<string, string>();
for (const a of articles) map.set(a.id, a.name);
return map;
}, [articles]);
const locationNames = useMemo(() => {
const map = new Map<string, string>();
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 };
}