diff --git a/frontend/apps/cli/src/App.tsx b/frontend/apps/cli/src/App.tsx
index 4ca23d2..3926a60 100644
--- a/frontend/apps/cli/src/App.tsx
+++ b/frontend/apps/cli/src/App.tsx
@@ -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' && }
{current === 'stock-movement-detail' && }
{current === 'stock-movement-record' && }
+ {current === 'inventory-count-list' && }
+ {current === 'inventory-count-create' && }
+ {current === 'inventory-count-detail' && }
{/* Produktion */}
{current === 'production-menu' && }
{current === 'recipe-list' && }
diff --git a/frontend/apps/cli/src/components/inventory/InventoryCountCreateScreen.tsx b/frontend/apps/cli/src/components/inventory/InventoryCountCreateScreen.tsx
new file mode 100644
index 0000000..2bf7f1b
--- /dev/null
+++ b/frontend/apps/cli/src/components/inventory/InventoryCountCreateScreen.tsx
@@ -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('storageLocationId');
+ const [fieldErrors, setFieldErrors] = useState>>({});
+
+ useEffect(() => {
+ void fetchStorageLocations();
+ }, [fetchStorageLocations]);
+
+ const handleSubmit = async () => {
+ const errors: Partial> = {};
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+ Neue Inventur
+ {error && }
+
+
+ {/* Storage location selector */}
+
+ Lagerort * (↑↓ auswählen)
+ {fieldErrors.storageLocationId && {fieldErrors.storageLocationId}}
+
+ {storageLocations.length === 0 ? (
+ Keine Lagerorte verfügbar.
+ ) : (
+ 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 (
+
+ {isSelected ? '▶ ' : ' '}{loc.name}
+
+ );
+ })
+ )}
+
+
+
+ {/* Count date */}
+ void handleSubmit()}
+ focus={activeField === 'countDate'}
+ error={fieldErrors.countDate}
+ placeholder="2026-03-18"
+ />
+
+
+
+
+ ↑↓/Tab Feld wechseln · Enter bestätigen/speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/inventory/InventoryCountDetailScreen.tsx b/frontend/apps/cli/src/components/inventory/InventoryCountDetailScreen.tsx
new file mode 100644
index 0000000..28515e4
--- /dev/null
+++ b/frontend/apps/cli/src/components/inventory/InventoryCountDetailScreen.tsx
@@ -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 = {
+ 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('view');
+ const [menuIndex, setMenuIndex] = useState(0);
+ const [itemIndex, setItemIndex] = useState(0);
+ const [amount, setAmount] = useState('');
+ const [success, setSuccess] = useState(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 ;
+
+ if (!inventoryCount) {
+ return (
+
+ {error && }
+ Inventur nicht gefunden.
+
+ );
+ }
+
+ 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 (
+
+
+ Inventur
+ {loading && (aktualisiere...)}
+
+
+ {error && }
+ {success && setSuccess(null)} />}
+
+ {/* Header */}
+
+ Lagerort: {locationName(inventoryCount.storageLocationId)}
+ Datum: {inventoryCount.countDate}
+ Status: {statusLabel}
+ Erstellt von: {inventoryCount.initiatedBy}
+ {inventoryCount.completedBy && (
+ Abgeschl. von: {inventoryCount.completedBy}
+ )}
+
+ Fortschritt:
+ {progressBar} {countedCount}/{items.length} ({progressPct}%)
+
+
+
+ {/* Count Items Table */}
+
+ Positionen ({items.length})
+ {items.length === 0 ? (
+ Keine Positionen vorhanden.
+ ) : (
+ <>
+
+ {' Artikel'.padEnd(26)}
+ {'Soll'.padEnd(14)}
+ {'Ist'.padEnd(14)}
+ Abweichung
+
+ {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 (
+
+ {isSelected ? '▶ ' : ' '}
+ {name.substring(0, 22).padEnd(23)}
+ {expected.padEnd(14)}
+ {actual.padEnd(14)}
+ {deviation}
+
+ );
+ })}
+ {items.length > VISIBLE_ITEMS && (
+ … {items.length - VISIBLE_ITEMS} weitere Positionen (scrollen mit ↑↓)
+ )}
+ >
+ )}
+
+
+ {/* Menu */}
+ {mode === 'menu' && (
+
+ Aktionen
+ {menuItems.map((item, i) => (
+
+ {i === menuIndex ? '▶ ' : ' '}{item.label}
+
+ ))}
+ ↑↓ nav · Enter ausführen · Escape zurück
+
+ )}
+
+ {/* Record Item mode */}
+ {mode === 'record-item' && (
+
+ Position erfassen
+ ↑↓ Position wählen · Enter Ist-Menge eingeben · Escape zurück
+
+ )}
+
+ {/* Record Amount */}
+ {mode === 'record-amount' && selectedItem && (
+
+ Ist-Menge für: {articleName(selectedItem.articleId)}
+ Soll: {selectedItem.expectedQuantityAmount} {selectedItem.expectedQuantityUnit}
+
+ ›
+ void handleRecordItem()}
+ focus={true}
+ />
+ {selectedItem.expectedQuantityUnit}
+
+ Enter bestätigen · Escape abbrechen
+
+ )}
+
+ {/* Confirm Start */}
+ {mode === 'confirm-start' && (
+
+ Zählung starten?
+ Die Inventur wird in den Status "In Zählung" versetzt.
+ [J] Ja · [N] Nein
+
+ )}
+
+ {/* Confirm Complete */}
+ {mode === 'confirm-complete' && (
+
+ Inventur abschließen?
+ Alle {items.length} Positionen wurden gezählt. Ausgleichsbuchungen werden erstellt.
+ [J] Ja · [N] Nein
+
+ )}
+
+ {/* Four-eyes hint */}
+ {inventoryCount.status === 'COUNTING' && allCounted && !isDifferentUser && (
+
+ ⚠ Vier-Augen-Prinzip: Abschluss nur durch einen anderen Benutzer möglich.
+
+ )}
+
+
+
+ {menuItems.length > 0 ? '[m] Aktionsmenü · ' : ''}[r] Refresh · Backspace Zurück
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/inventory/InventoryCountListScreen.tsx b/frontend/apps/cli/src/components/inventory/InventoryCountListScreen.tsx
new file mode 100644
index 0000000..48fd30e
--- /dev/null
+++ b/frontend/apps/cli/src/components/inventory/InventoryCountListScreen.tsx
@@ -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 = {
+ 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('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 (
+
+
+ Inventuren
+
+ Status: {filterLabel}
+ {' '}({filtered.length})
+
+
+
+ {loading && }
+ {error && !loading && }
+
+ {!loading && !error && (
+
+
+ {' Status'.padEnd(18)}
+ {'Lagerort'.padEnd(22)}
+ {'Datum'.padEnd(14)}
+ {'Fortschritt'.padEnd(14)}
+ Erstellt von
+
+ {filtered.length === 0 && (
+
+ Keine Inventuren gefunden.
+
+ )}
+ {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 (
+
+ {isSelected ? '▶ ' : ' '}
+ {statusLabel.padEnd(15)}
+ {locationName(count.storageLocationId).substring(0, 20).padEnd(21)}
+ {count.countDate.padEnd(14)}
+ {progress.padEnd(14)}
+ {count.initiatedBy}
+
+ );
+ })}
+
+ )}
+
+
+
+ ↑↓ nav · Enter Details · [n] Neu · [f] Filter · [r] Refresh · Backspace Zurück
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/inventory/InventoryMenu.tsx b/frontend/apps/cli/src/components/inventory/InventoryMenu.tsx
index 23ac13f..31a4f7d 100644
--- a/frontend/apps/cli/src/components/inventory/InventoryMenu.tsx
+++ b/frontend/apps/cli/src/components/inventory/InventoryMenu.tsx
@@ -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() {
diff --git a/frontend/apps/cli/src/hooks/useInventoryCounts.ts b/frontend/apps/cli/src/hooks/useInventoryCounts.ts
new file mode 100644
index 0000000..d1f8e2e
--- /dev/null
+++ b/frontend/apps/cli/src/hooks/useInventoryCounts.ts
@@ -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({
+ 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,
+ };
+}
diff --git a/frontend/apps/cli/src/state/navigation-context.tsx b/frontend/apps/cli/src/state/navigation-context.tsx
index ba8783e..bb2b904 100644
--- a/frontend/apps/cli/src/state/navigation-context.tsx
+++ b/frontend/apps/cli/src/state/navigation-context.tsx
@@ -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;
diff --git a/frontend/packages/api-client/src/index.ts b/frontend/packages/api-client/src/index.ts
index b990c60..8acc511 100644
--- a/frontend/packages/api-client/src/index.ts
+++ b/frontend/packages/api-client/src/index.ts
@@ -29,6 +29,7 @@ export { createProductionOrdersResource } from './resources/production-orders.js
export { createStocksResource } from './resources/stocks.js';
export { createStockMovementsResource } from './resources/stock-movements.js';
export { createCountriesResource } from './resources/countries.js';
+export { createInventoryCountsResource } from './resources/inventory-counts.js';
export {
ApiError,
AuthenticationError,
@@ -116,6 +117,11 @@ export type {
StockMovementDTO,
RecordStockMovementRequest,
CountryDTO,
+ InventoryCountDTO,
+ CountItemDTO,
+ InventoryCountStatus,
+ CreateInventoryCountRequest,
+ RecordCountItemRequest,
} from '@effigenix/types';
// Resource types (runtime, stay in resource files)
@@ -149,6 +155,8 @@ export type { StocksResource, BatchType, StockBatchStatus, StockFilter, Referenc
export type { StockMovementsResource, MovementType, MovementDirection, StockMovementFilter } from './resources/stock-movements.js';
export { MOVEMENT_TYPE_LABELS, MOVEMENT_DIRECTION_LABELS } from './resources/stock-movements.js';
export type { CountriesResource } from './resources/countries.js';
+export type { InventoryCountsResource, InventoryCountFilter } from './resources/inventory-counts.js';
+export { INVENTORY_COUNT_STATUS_LABELS } from './resources/inventory-counts.js';
export { BATCH_TYPE_LABELS, STOCK_BATCH_STATUS_LABELS, REFERENCE_TYPE_LABELS, RESERVATION_PRIORITY_LABELS } from './resources/stocks.js';
import { createApiClient } from './client.js';
@@ -166,6 +174,7 @@ import { createProductionOrdersResource } from './resources/production-orders.js
import { createStocksResource } from './resources/stocks.js';
import { createStockMovementsResource } from './resources/stock-movements.js';
import { createCountriesResource } from './resources/countries.js';
+import { createInventoryCountsResource } from './resources/inventory-counts.js';
import type { TokenProvider } from './token-provider.js';
import type { ApiConfig } from '@effigenix/config';
@@ -194,6 +203,7 @@ export function createEffigenixClient(
stocks: createStocksResource(axiosClient),
stockMovements: createStockMovementsResource(axiosClient),
countries: createCountriesResource(axiosClient),
+ inventoryCounts: createInventoryCountsResource(axiosClient),
};
}
diff --git a/frontend/packages/api-client/src/resources/inventory-counts.ts b/frontend/packages/api-client/src/resources/inventory-counts.ts
new file mode 100644
index 0000000..57d1059
--- /dev/null
+++ b/frontend/packages/api-client/src/resources/inventory-counts.ts
@@ -0,0 +1,62 @@
+/** Inventory Counts resource – Inventory BC. */
+
+import type { AxiosInstance } from 'axios';
+import type {
+ InventoryCountDTO,
+ CreateInventoryCountRequest,
+ RecordCountItemRequest,
+ InventoryCountStatus,
+} from '@effigenix/types';
+
+export type { InventoryCountDTO, CreateInventoryCountRequest, RecordCountItemRequest, InventoryCountStatus };
+
+export const INVENTORY_COUNT_STATUS_LABELS: Record = {
+ OPEN: 'Offen',
+ COUNTING: 'In Zählung',
+ COMPLETED: 'Abgeschlossen',
+ CANCELLED: 'Abgebrochen',
+};
+
+export interface InventoryCountFilter {
+ storageLocationId?: string;
+}
+
+const BASE = '/api/inventory/inventory-counts';
+
+export function createInventoryCountsResource(client: AxiosInstance) {
+ return {
+ async list(filter?: InventoryCountFilter): Promise {
+ const params: Record = {};
+ if (filter?.storageLocationId) params.storageLocationId = filter.storageLocationId;
+ const res = await client.get(BASE, { params });
+ return res.data;
+ },
+
+ async getById(id: string): Promise {
+ const res = await client.get(`${BASE}/${id}`);
+ return res.data;
+ },
+
+ async create(request: CreateInventoryCountRequest): Promise {
+ const res = await client.post(BASE, request);
+ return res.data;
+ },
+
+ async start(id: string): Promise {
+ const res = await client.patch(`${BASE}/${id}/start`);
+ return res.data;
+ },
+
+ async recordItem(countId: string, itemId: string, request: RecordCountItemRequest): Promise {
+ const res = await client.patch(`${BASE}/${countId}/items/${itemId}`, request);
+ return res.data;
+ },
+
+ async complete(id: string): Promise {
+ const res = await client.post(`${BASE}/${id}/complete`);
+ return res.data;
+ },
+ };
+}
+
+export type InventoryCountsResource = ReturnType;
diff --git a/frontend/packages/types/src/index.ts b/frontend/packages/types/src/index.ts
index 3be5999..bde13c9 100644
--- a/frontend/packages/types/src/index.ts
+++ b/frontend/packages/types/src/index.ts
@@ -18,6 +18,7 @@ export * from './customer';
export * from './inventory';
export * from './production';
export * from './country';
+export * from './inventory-count';
// Re-export generated types for advanced usage
export type { components, paths } from './generated/api';
diff --git a/frontend/packages/types/src/inventory-count.ts b/frontend/packages/types/src/inventory-count.ts
new file mode 100644
index 0000000..edd5d75
--- /dev/null
+++ b/frontend/packages/types/src/inventory-count.ts
@@ -0,0 +1,36 @@
+/**
+ * Inventory Count types (manual – not in OpenAPI spec)
+ */
+
+export type InventoryCountStatus = 'OPEN' | 'COUNTING' | 'COMPLETED' | 'CANCELLED';
+
+export interface CountItemDTO {
+ id: string;
+ articleId: string;
+ expectedQuantityAmount: string;
+ expectedQuantityUnit: string;
+ actualQuantityAmount: string | null;
+ actualQuantityUnit: string | null;
+ deviation: string | null;
+}
+
+export interface InventoryCountDTO {
+ id: string;
+ storageLocationId: string;
+ countDate: string;
+ initiatedBy: string;
+ completedBy: string | null;
+ status: InventoryCountStatus;
+ createdAt: string;
+ countItems: CountItemDTO[];
+}
+
+export interface CreateInventoryCountRequest {
+ storageLocationId: string;
+ countDate: string;
+}
+
+export interface RecordCountItemRequest {
+ actualQuantityAmount: string;
+ actualQuantityUnit: string;
+}