mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +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:
parent
376557925a
commit
e25d4437d9
5 changed files with 281 additions and 47 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
122
frontend/apps/cli/src/components/shared/StockPicker.tsx
Normal file
122
frontend/apps/cli/src/components/shared/StockPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/apps/cli/src/hooks/useStockNameLookup.ts
Normal file
37
frontend/apps/cli/src/hooks/useStockNameLookup.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue