mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:39:35 +01:00
feat(inventory): Tui Inventur anlegen, starten, zählen, abschließen
This commit is contained in:
parent
ae95a0284f
commit
85a3f634fd
11 changed files with 804 additions and 1 deletions
|
|
@ -61,6 +61,9 @@ import { StockListScreen } from './components/inventory/StockListScreen.js';
|
|||
import { StockDetailScreen } from './components/inventory/StockDetailScreen.js';
|
||||
import { StockCreateScreen } from './components/inventory/StockCreateScreen.js';
|
||||
import { ReserveStockScreen } from './components/inventory/ReserveStockScreen.js';
|
||||
import { InventoryCountListScreen } from './components/inventory/InventoryCountListScreen.js';
|
||||
import { InventoryCountCreateScreen } from './components/inventory/InventoryCountCreateScreen.js';
|
||||
import { InventoryCountDetailScreen } from './components/inventory/InventoryCountDetailScreen.js';
|
||||
|
||||
function ScreenRouter() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
|
@ -132,6 +135,9 @@ function ScreenRouter() {
|
|||
{current === 'stock-movement-list' && <StockMovementListScreen />}
|
||||
{current === 'stock-movement-detail' && <StockMovementDetailScreen />}
|
||||
{current === 'stock-movement-record' && <StockMovementRecordScreen />}
|
||||
{current === 'inventory-count-list' && <InventoryCountListScreen />}
|
||||
{current === 'inventory-count-create' && <InventoryCountCreateScreen />}
|
||||
{current === 'inventory-count-detail' && <InventoryCountDetailScreen />}
|
||||
{/* Produktion */}
|
||||
{current === 'production-menu' && <ProductionMenu />}
|
||||
{current === 'recipe-list' && <RecipeListScreen />}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { useInventoryCounts } from '../../hooks/useInventoryCounts.js';
|
||||
import { useStorageLocations } from '../../hooks/useStorageLocations.js';
|
||||
import { FormInput } from '../shared/FormInput.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
|
||||
type Field = 'storageLocationId' | 'countDate';
|
||||
const FIELDS: Field[] = ['storageLocationId', 'countDate'];
|
||||
|
||||
function todayISO(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function InventoryCountCreateScreen() {
|
||||
const { replace, back } = useNavigation();
|
||||
const { createInventoryCount, loading, error, clearError } = useInventoryCounts();
|
||||
const { storageLocations, fetchStorageLocations } = useStorageLocations();
|
||||
|
||||
const [locationIdx, setLocationIdx] = useState(0);
|
||||
const [countDate, setCountDate] = useState(todayISO());
|
||||
const [activeField, setActiveField] = useState<Field>('storageLocationId');
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStorageLocations();
|
||||
}, [fetchStorageLocations]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const errors: Partial<Record<Field, string>> = {};
|
||||
if (storageLocations.length === 0) errors.storageLocationId = 'Kein Lagerort verfügbar.';
|
||||
if (!countDate.trim()) errors.countDate = 'Datum muss angegeben werden.';
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length > 0) return;
|
||||
|
||||
const location = storageLocations[locationIdx]!;
|
||||
const result = await createInventoryCount({
|
||||
storageLocationId: location.id,
|
||||
countDate: countDate.trim(),
|
||||
});
|
||||
if (result) replace('inventory-count-detail', { inventoryCountId: result.id });
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
|
||||
if (activeField === 'storageLocationId') {
|
||||
if (key.upArrow) {
|
||||
if (locationIdx > 0) setLocationIdx((i) => i - 1);
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
if (locationIdx < storageLocations.length - 1) setLocationIdx((i) => i + 1);
|
||||
else setActiveField('countDate');
|
||||
return;
|
||||
}
|
||||
if (key.return || key.tab) {
|
||||
setActiveField('countDate');
|
||||
return;
|
||||
}
|
||||
if (key.escape) back();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeField === 'countDate') {
|
||||
if (key.escape) back();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center" paddingY={2}>
|
||||
<LoadingSpinner label="Inventur wird angelegt..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Neue Inventur</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
{/* Storage location selector */}
|
||||
<Box flexDirection="column">
|
||||
<Text color={activeField === 'storageLocationId' ? 'cyan' : 'gray'}>Lagerort * (↑↓ auswählen)</Text>
|
||||
{fieldErrors.storageLocationId && <Text color="red">{fieldErrors.storageLocationId}</Text>}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={activeField === 'storageLocationId' ? 'cyan' : 'gray'} paddingX={1}>
|
||||
{storageLocations.length === 0 ? (
|
||||
<Text color="gray" dimColor>Keine Lagerorte verfügbar.</Text>
|
||||
) : (
|
||||
storageLocations.slice(Math.max(0, locationIdx - 3), locationIdx + 4).map((loc, i) => {
|
||||
const actualIdx = Math.max(0, locationIdx - 3) + i;
|
||||
const isSelected = actualIdx === locationIdx;
|
||||
return (
|
||||
<Text key={loc.id} color={isSelected ? 'cyan' : 'white'}>
|
||||
{isSelected ? '▶ ' : ' '}{loc.name}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Count date */}
|
||||
<FormInput
|
||||
label="Datum * (YYYY-MM-DD)"
|
||||
value={countDate}
|
||||
onChange={setCountDate}
|
||||
onSubmit={() => void handleSubmit()}
|
||||
focus={activeField === 'countDate'}
|
||||
error={fieldErrors.countDate}
|
||||
placeholder="2026-03-18"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓/Tab Feld wechseln · Enter bestätigen/speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { useAuth } from '../../state/auth-context.js';
|
||||
import { useInventoryCounts } from '../../hooks/useInventoryCounts.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 { INVENTORY_COUNT_STATUS_LABELS } from '@effigenix/api-client';
|
||||
import type { InventoryCountStatus, CountItemDTO } from '@effigenix/api-client';
|
||||
|
||||
type Mode = 'view' | 'menu' | 'record-item' | 'record-amount' | 'confirm-start' | 'confirm-complete';
|
||||
|
||||
const STATUS_COLORS: Record<InventoryCountStatus, string> = {
|
||||
OPEN: 'yellow',
|
||||
COUNTING: 'blue',
|
||||
COMPLETED: 'green',
|
||||
CANCELLED: 'red',
|
||||
};
|
||||
|
||||
const VISIBLE_ITEMS = 7;
|
||||
|
||||
export function InventoryCountDetailScreen() {
|
||||
const { params, back } = useNavigation();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
inventoryCount,
|
||||
loading,
|
||||
error,
|
||||
fetchInventoryCount,
|
||||
startInventoryCount,
|
||||
recordCountItem,
|
||||
completeInventoryCount,
|
||||
clearError,
|
||||
} = useInventoryCounts();
|
||||
const { articleName, locationName } = useStockNameLookup();
|
||||
|
||||
const [mode, setMode] = useState<Mode>('view');
|
||||
const [menuIndex, setMenuIndex] = useState(0);
|
||||
const [itemIndex, setItemIndex] = useState(0);
|
||||
const [amount, setAmount] = useState('');
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const countId = params.inventoryCountId ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (countId) void fetchInventoryCount(countId);
|
||||
}, [fetchInventoryCount, countId]);
|
||||
|
||||
const items: CountItemDTO[] = inventoryCount?.countItems ?? [];
|
||||
const selectedItem = items[itemIndex];
|
||||
const countedCount = items.filter((i) => i.actualQuantityAmount !== null).length;
|
||||
const allCounted = items.length > 0 && countedCount === items.length;
|
||||
const currentUser = user?.username ?? '';
|
||||
const isDifferentUser = inventoryCount?.initiatedBy !== currentUser;
|
||||
|
||||
const getMenuItems = () => {
|
||||
if (!inventoryCount) return [];
|
||||
const actions: { label: string; action: string }[] = [];
|
||||
if (inventoryCount.status === 'OPEN') {
|
||||
actions.push({ label: 'Zählung starten', action: 'start' });
|
||||
}
|
||||
if (inventoryCount.status === 'COUNTING') {
|
||||
actions.push({ label: 'Position erfassen', action: 'record' });
|
||||
if (allCounted && isDifferentUser) {
|
||||
actions.push({ label: 'Inventur abschließen', action: 'complete' });
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
|
||||
const menuItems = getMenuItems();
|
||||
|
||||
const handleStart = async () => {
|
||||
const result = await startInventoryCount(countId);
|
||||
if (result) {
|
||||
setSuccess('Zählung gestartet.');
|
||||
setMode('view');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecordItem = async () => {
|
||||
if (!selectedItem || !amount.trim()) return;
|
||||
const result = await recordCountItem(countId, selectedItem.id, {
|
||||
actualQuantityAmount: amount.trim(),
|
||||
actualQuantityUnit: selectedItem.expectedQuantityUnit,
|
||||
});
|
||||
if (result) {
|
||||
setSuccess(`${articleName(selectedItem.articleId)} erfasst.`);
|
||||
setAmount('');
|
||||
// advance to next uncounted item or stay
|
||||
const updated = result.countItems ?? [];
|
||||
const nextUncounted = updated.findIndex((i, idx) => idx > itemIndex && i.actualQuantityAmount === null);
|
||||
if (nextUncounted >= 0) {
|
||||
setItemIndex(nextUncounted);
|
||||
}
|
||||
setMode('record-item');
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
const result = await completeInventoryCount(countId);
|
||||
if (result) {
|
||||
setSuccess('Inventur abgeschlossen.');
|
||||
setMode('view');
|
||||
}
|
||||
};
|
||||
|
||||
useInput((input, key) => {
|
||||
if (loading) return;
|
||||
|
||||
if (mode === 'record-amount') {
|
||||
if (key.escape) setMode('record-item');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'confirm-start') {
|
||||
if (input.toLowerCase() === 'j') void handleStart();
|
||||
if (input.toLowerCase() === 'n' || key.escape) setMode('view');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'confirm-complete') {
|
||||
if (input.toLowerCase() === 'j') void handleComplete();
|
||||
if (input.toLowerCase() === 'n' || key.escape) setMode('view');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'record-item') {
|
||||
if (key.upArrow) setItemIndex((i) => Math.max(0, i - 1));
|
||||
if (key.downArrow) setItemIndex((i) => Math.min(items.length - 1, i + 1));
|
||||
if (key.return && selectedItem) {
|
||||
setAmount(selectedItem.actualQuantityAmount ?? '');
|
||||
setMode('record-amount');
|
||||
}
|
||||
if (key.escape) setMode('view');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'menu') {
|
||||
if (key.upArrow) setMenuIndex((i) => Math.max(0, i - 1));
|
||||
if (key.downArrow) setMenuIndex((i) => Math.min(menuItems.length - 1, i + 1));
|
||||
if (key.return && menuItems[menuIndex]) {
|
||||
const action = menuItems[menuIndex].action;
|
||||
if (action === 'start') setMode('confirm-start');
|
||||
else if (action === 'record') { setMode('record-item'); setItemIndex(0); }
|
||||
else if (action === 'complete') setMode('confirm-complete');
|
||||
}
|
||||
if (key.escape) setMode('view');
|
||||
return;
|
||||
}
|
||||
|
||||
// view mode
|
||||
if (input === 'm' && menuItems.length > 0) { setMode('menu'); setMenuIndex(0); }
|
||||
if (input === 'r') void fetchInventoryCount(countId);
|
||||
if (key.backspace || key.escape) back();
|
||||
});
|
||||
|
||||
if (loading && !inventoryCount) return <LoadingSpinner label="Lade Inventur..." />;
|
||||
|
||||
if (!inventoryCount) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
<Text color="red">Inventur nicht gefunden.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const status = inventoryCount.status as InventoryCountStatus;
|
||||
const statusColor = STATUS_COLORS[status] ?? 'white';
|
||||
const statusLabel = INVENTORY_COUNT_STATUS_LABELS[status] ?? status;
|
||||
|
||||
// progress bar
|
||||
const progressPct = items.length > 0 ? Math.round((countedCount / items.length) * 100) : 0;
|
||||
const barWidth = 20;
|
||||
const filled = Math.round((progressPct / 100) * barWidth);
|
||||
const progressBar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
|
||||
// scrolling window for items
|
||||
const halfWindow = Math.floor(VISIBLE_ITEMS / 2);
|
||||
let windowStart = Math.max(0, itemIndex - halfWindow);
|
||||
const windowEnd = Math.min(items.length, windowStart + VISIBLE_ITEMS);
|
||||
if (windowEnd - windowStart < VISIBLE_ITEMS && items.length >= VISIBLE_ITEMS) {
|
||||
windowStart = Math.max(0, windowEnd - VISIBLE_ITEMS);
|
||||
}
|
||||
const visibleItems = items.slice(windowStart, windowEnd);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="cyan" bold>Inventur</Text>
|
||||
{loading && <Text color="gray"> (aktualisiere...)</Text>}
|
||||
</Box>
|
||||
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
{success && <SuccessDisplay message={success} onDismiss={() => setSuccess(null)} />}
|
||||
|
||||
{/* Header */}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
<Box><Text color="gray">Lagerort: </Text><Text>{locationName(inventoryCount.storageLocationId)}</Text></Box>
|
||||
<Box><Text color="gray">Datum: </Text><Text>{inventoryCount.countDate}</Text></Box>
|
||||
<Box><Text color="gray">Status: </Text><Text color={statusColor} bold>{statusLabel}</Text></Box>
|
||||
<Box><Text color="gray">Erstellt von: </Text><Text>{inventoryCount.initiatedBy}</Text></Box>
|
||||
{inventoryCount.completedBy && (
|
||||
<Box><Text color="gray">Abgeschl. von: </Text><Text>{inventoryCount.completedBy}</Text></Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text color="gray">Fortschritt: </Text>
|
||||
<Text color={allCounted ? 'green' : 'yellow'}>{progressBar} {countedCount}/{items.length} ({progressPct}%)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Count Items Table */}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
<Text color="cyan" bold>Positionen ({items.length})</Text>
|
||||
{items.length === 0 ? (
|
||||
<Text color="gray" dimColor>Keine Positionen vorhanden.</Text>
|
||||
) : (
|
||||
<>
|
||||
<Box paddingX={1}>
|
||||
<Text color="gray" bold>{' Artikel'.padEnd(26)}</Text>
|
||||
<Text color="gray" bold>{'Soll'.padEnd(14)}</Text>
|
||||
<Text color="gray" bold>{'Ist'.padEnd(14)}</Text>
|
||||
<Text color="gray" bold>Abweichung</Text>
|
||||
</Box>
|
||||
{visibleItems.map((item, i) => {
|
||||
const actualIdx = windowStart + i;
|
||||
const isSelected = mode === 'record-item' && actualIdx === itemIndex;
|
||||
const isCounted = item.actualQuantityAmount !== null;
|
||||
const hasDeviation = isCounted && item.deviation !== null && item.deviation !== '0' && item.deviation !== '0.0';
|
||||
const rowColor = isSelected ? 'cyan' : isCounted ? (hasDeviation ? 'yellow' : 'green') : 'gray';
|
||||
const name = articleName(item.articleId);
|
||||
const expected = `${item.expectedQuantityAmount} ${item.expectedQuantityUnit}`;
|
||||
const actual = isCounted ? `${item.actualQuantityAmount} ${item.actualQuantityUnit}` : '---';
|
||||
const deviation = isCounted && item.deviation !== null ? item.deviation : '---';
|
||||
return (
|
||||
<Box key={item.id} paddingX={1}>
|
||||
<Text color={rowColor}>{isSelected ? '▶ ' : ' '}</Text>
|
||||
<Text color={rowColor}>{name.substring(0, 22).padEnd(23)}</Text>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'}>{expected.padEnd(14)}</Text>
|
||||
<Text color={isSelected ? 'cyan' : (isCounted ? rowColor : 'gray')}>{actual.padEnd(14)}</Text>
|
||||
<Text color={isSelected ? 'cyan' : (hasDeviation ? 'yellow' : 'gray')}>{deviation}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{items.length > VISIBLE_ITEMS && (
|
||||
<Text color="gray" dimColor> … {items.length - VISIBLE_ITEMS} weitere Positionen (scrollen mit ↑↓)</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Menu */}
|
||||
{mode === 'menu' && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>Aktionen</Text>
|
||||
{menuItems.map((item, i) => (
|
||||
<Text key={item.action} color={i === menuIndex ? 'cyan' : 'white'}>
|
||||
{i === menuIndex ? '▶ ' : ' '}{item.label}
|
||||
</Text>
|
||||
))}
|
||||
<Text color="gray" dimColor>↑↓ nav · Enter ausführen · Escape zurück</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Record Item mode */}
|
||||
{mode === 'record-item' && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>Position erfassen</Text>
|
||||
<Text color="gray" dimColor>↑↓ Position wählen · Enter Ist-Menge eingeben · Escape zurück</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Record Amount */}
|
||||
{mode === 'record-amount' && selectedItem && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>Ist-Menge für: {articleName(selectedItem.articleId)}</Text>
|
||||
<Text color="gray">Soll: {selectedItem.expectedQuantityAmount} {selectedItem.expectedQuantityUnit}</Text>
|
||||
<Box>
|
||||
<Text color="gray"> › </Text>
|
||||
<TextInput
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
onSubmit={() => void handleRecordItem()}
|
||||
focus={true}
|
||||
/>
|
||||
<Text color="gray"> {selectedItem.expectedQuantityUnit}</Text>
|
||||
</Box>
|
||||
<Text color="gray" dimColor>Enter bestätigen · Escape abbrechen</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Confirm Start */}
|
||||
{mode === 'confirm-start' && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>Zählung starten?</Text>
|
||||
<Text>Die Inventur wird in den Status "In Zählung" versetzt.</Text>
|
||||
<Text color="gray" dimColor>[J] Ja · [N] Nein</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Confirm Complete */}
|
||||
{mode === 'confirm-complete' && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>Inventur abschließen?</Text>
|
||||
<Text>Alle {items.length} Positionen wurden gezählt. Ausgleichsbuchungen werden erstellt.</Text>
|
||||
<Text color="gray" dimColor>[J] Ja · [N] Nein</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Four-eyes hint */}
|
||||
{inventoryCount.status === 'COUNTING' && allCounted && !isDifferentUser && (
|
||||
<Box>
|
||||
<Text color="yellow">⚠ Vier-Augen-Prinzip: Abschluss nur durch einen anderen Benutzer möglich.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
{menuItems.length > 0 ? '[m] Aktionsmenü · ' : ''}[r] Refresh · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { useInventoryCounts } from '../../hooks/useInventoryCounts.js';
|
||||
import { useStockNameLookup } from '../../hooks/useStockNameLookup.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
import { INVENTORY_COUNT_STATUS_LABELS } from '@effigenix/api-client';
|
||||
import type { InventoryCountStatus } from '@effigenix/api-client';
|
||||
|
||||
type StatusFilter = 'ALL' | InventoryCountStatus;
|
||||
const FILTER_CYCLE: StatusFilter[] = ['ALL', 'OPEN', 'COUNTING', 'COMPLETED', 'CANCELLED'];
|
||||
|
||||
const STATUS_COLORS: Record<InventoryCountStatus, string> = {
|
||||
OPEN: 'yellow',
|
||||
COUNTING: 'blue',
|
||||
COMPLETED: 'green',
|
||||
CANCELLED: 'red',
|
||||
};
|
||||
|
||||
export function InventoryCountListScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { inventoryCounts, loading, error, fetchInventoryCounts, clearError } = useInventoryCounts();
|
||||
const { locationName } = useStockNameLookup();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||
|
||||
useEffect(() => {
|
||||
void fetchInventoryCounts();
|
||||
}, [fetchInventoryCounts]);
|
||||
|
||||
const filtered = statusFilter === 'ALL'
|
||||
? inventoryCounts
|
||||
: inventoryCounts.filter((c) => c.status === statusFilter);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (loading) return;
|
||||
|
||||
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 item = filtered[selectedIndex];
|
||||
if (item) navigate('inventory-count-detail', { inventoryCountId: item.id });
|
||||
}
|
||||
if (input === 'n') navigate('inventory-count-create');
|
||||
if (input === 'r') void fetchInventoryCounts();
|
||||
if (input === 'f') {
|
||||
setStatusFilter((current) => {
|
||||
const idx = FILTER_CYCLE.indexOf(current);
|
||||
return FILTER_CYCLE[(idx + 1) % FILTER_CYCLE.length]!;
|
||||
});
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
if (key.backspace || key.escape) back();
|
||||
});
|
||||
|
||||
const filterLabel = statusFilter === 'ALL' ? 'Alle' : INVENTORY_COUNT_STATUS_LABELS[statusFilter];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="cyan" bold>Inventuren</Text>
|
||||
<Text color="gray" dimColor>
|
||||
Status: <Text color="yellow">{filterLabel}</Text>
|
||||
{' '}({filtered.length})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{loading && <LoadingSpinner label="Lade Inventuren..." />}
|
||||
{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>{' Status'.padEnd(18)}</Text>
|
||||
<Text color="gray" bold>{'Lagerort'.padEnd(22)}</Text>
|
||||
<Text color="gray" bold>{'Datum'.padEnd(14)}</Text>
|
||||
<Text color="gray" bold>{'Fortschritt'.padEnd(14)}</Text>
|
||||
<Text color="gray" bold>Erstellt von</Text>
|
||||
</Box>
|
||||
{filtered.length === 0 && (
|
||||
<Box paddingX={1} paddingY={1}>
|
||||
<Text color="gray" dimColor>Keine Inventuren gefunden.</Text>
|
||||
</Box>
|
||||
)}
|
||||
{filtered.map((count, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const textColor = isSelected ? 'cyan' : 'white';
|
||||
const statusColor = STATUS_COLORS[count.status] ?? 'white';
|
||||
const statusLabel = INVENTORY_COUNT_STATUS_LABELS[count.status] ?? count.status;
|
||||
const counted = count.countItems.filter((i) => i.actualQuantityAmount !== null).length;
|
||||
const total = count.countItems.length;
|
||||
const progress = `${counted}/${total}`;
|
||||
return (
|
||||
<Box key={count.id} paddingX={1}>
|
||||
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
|
||||
<Text color={statusColor}>{statusLabel.padEnd(15)}</Text>
|
||||
<Text color={textColor}>{locationName(count.storageLocationId).substring(0, 20).padEnd(21)}</Text>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'}>{count.countDate.padEnd(14)}</Text>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'}>{progress.padEnd(14)}</Text>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'}>{count.initiatedBy}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ nav · Enter Details · [n] Neu · [f] Filter · [r] Refresh · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ const MENU_ITEMS: MenuItem[] = [
|
|||
{ label: 'Bestände', screen: 'stock-list', description: 'Bestände einsehen, anlegen und Chargen verwalten' },
|
||||
{ label: 'Bestandsbewegungen', screen: 'stock-movement-list', description: 'Wareneingänge, Verbräuche, Korrekturen und Umlagerungen' },
|
||||
{ label: 'Charge einbuchen', screen: 'stock-batch-entry', description: 'Neue Charge in einen Bestand einbuchen' },
|
||||
{ label: 'Inventuren', screen: 'inventory-count-list', description: 'Inventuren anlegen, zählen und abschließen' },
|
||||
];
|
||||
|
||||
export function InventoryMenu() {
|
||||
|
|
|
|||
111
frontend/apps/cli/src/hooks/useInventoryCounts.ts
Normal file
111
frontend/apps/cli/src/hooks/useInventoryCounts.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type {
|
||||
InventoryCountDTO,
|
||||
CreateInventoryCountRequest,
|
||||
RecordCountItemRequest,
|
||||
InventoryCountFilter,
|
||||
} from '@effigenix/api-client';
|
||||
import { client } from '../utils/api-client.js';
|
||||
|
||||
interface InventoryCountsState {
|
||||
inventoryCounts: InventoryCountDTO[];
|
||||
inventoryCount: InventoryCountDTO | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
export function useInventoryCounts() {
|
||||
const [state, setState] = useState<InventoryCountsState>({
|
||||
inventoryCounts: [],
|
||||
inventoryCount: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchInventoryCounts = useCallback(async (filter?: InventoryCountFilter) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const inventoryCounts = await client.inventoryCounts.list(filter);
|
||||
setState((s) => ({ ...s, inventoryCounts, loading: false, error: null }));
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchInventoryCount = useCallback(async (id: string) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const inventoryCount = await client.inventoryCounts.getById(id);
|
||||
setState((s) => ({ ...s, inventoryCount, loading: false, error: null }));
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createInventoryCount = useCallback(async (request: CreateInventoryCountRequest) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const inventoryCount = await client.inventoryCounts.create(request);
|
||||
setState((s) => ({ ...s, loading: false, error: null }));
|
||||
return inventoryCount;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startInventoryCount = useCallback(async (id: string) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const inventoryCount = await client.inventoryCounts.start(id);
|
||||
setState((s) => ({ ...s, inventoryCount, loading: false, error: null }));
|
||||
return inventoryCount;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const recordCountItem = useCallback(async (countId: string, itemId: string, request: RecordCountItemRequest) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const inventoryCount = await client.inventoryCounts.recordItem(countId, itemId, request);
|
||||
setState((s) => ({ ...s, inventoryCount, loading: false, error: null }));
|
||||
return inventoryCount;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completeInventoryCount = useCallback(async (id: string) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const inventoryCount = await client.inventoryCounts.complete(id);
|
||||
setState((s) => ({ ...s, inventoryCount, loading: false, error: null }));
|
||||
return inventoryCount;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState((s) => ({ ...s, error: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchInventoryCounts,
|
||||
fetchInventoryCount,
|
||||
createInventoryCount,
|
||||
startInventoryCount,
|
||||
recordCountItem,
|
||||
completeInventoryCount,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
|
@ -57,7 +57,11 @@ export type Screen =
|
|||
| 'production-order-list'
|
||||
| 'production-order-create'
|
||||
| 'production-order-detail'
|
||||
| 'stock-reserve';
|
||||
| 'stock-reserve'
|
||||
// Inventuren
|
||||
| 'inventory-count-list'
|
||||
| 'inventory-count-create'
|
||||
| 'inventory-count-detail';
|
||||
|
||||
interface NavigationState {
|
||||
current: Screen;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue