1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 11:59:35 +01:00

feat(inventory): Tui Inventur anlegen, starten, zählen, abschließen

This commit is contained in:
Sebastian Frick 2026-03-19 10:20:18 +01:00
parent ae95a0284f
commit 85a3f634fd
11 changed files with 804 additions and 1 deletions

View file

@ -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 />}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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() {

View 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,
};
}

View file

@ -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;