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; +}