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

feat(tui): Bestandsbewegungen und Produktionsaufträge anbinden

- StockMovement: API-Client, Hook, List/Detail/Record-Screens mit Typ-Filter
- ProductionOrder: list/getById/start im API-Client, List/Detail-Screens mit Freigabe- und Start-Aktion
- Inventar-Menü um Bestandsbewegungen erweitert
- Produktionsmenü zeigt jetzt Auftragsliste statt direkt Create
- OpenAPI-Typen regeneriert (StockMovementResponse, StartProductionOrderRequest, batchId in ProductionOrderResponse)
This commit is contained in:
Sebastian Frick 2026-02-25 12:36:42 +01:00
parent 0474b5fa93
commit 7d721f9ef0
18 changed files with 1279 additions and 9 deletions

View file

@ -39,6 +39,9 @@ import { StorageLocationDetailScreen } from './components/inventory/StorageLocat
import { StorageLocationEditScreen } from './components/inventory/StorageLocationEditScreen.js';
import { StockBatchEntryScreen } from './components/inventory/StockBatchEntryScreen.js';
import { AddBatchScreen } from './components/inventory/AddBatchScreen.js';
import { StockMovementListScreen } from './components/inventory/StockMovementListScreen.js';
import { StockMovementDetailScreen } from './components/inventory/StockMovementDetailScreen.js';
import { StockMovementRecordScreen } from './components/inventory/StockMovementRecordScreen.js';
// Produktion
import { ProductionMenu } from './components/production/ProductionMenu.js';
import { RecipeListScreen } from './components/production/RecipeListScreen.js';
@ -51,7 +54,9 @@ import { BatchDetailScreen } from './components/production/BatchDetailScreen.js'
import { BatchPlanScreen } from './components/production/BatchPlanScreen.js';
import { RecordConsumptionScreen } from './components/production/RecordConsumptionScreen.js';
import { CompleteBatchScreen } from './components/production/CompleteBatchScreen.js';
import { ProductionOrderListScreen } from './components/production/ProductionOrderListScreen.js';
import { ProductionOrderCreateScreen } from './components/production/ProductionOrderCreateScreen.js';
import { ProductionOrderDetailScreen } from './components/production/ProductionOrderDetailScreen.js';
import { StockListScreen } from './components/inventory/StockListScreen.js';
import { StockDetailScreen } from './components/inventory/StockDetailScreen.js';
import { StockCreateScreen } from './components/inventory/StockCreateScreen.js';
@ -124,6 +129,9 @@ function ScreenRouter() {
{current === 'stock-detail' && <StockDetailScreen />}
{current === 'stock-create' && <StockCreateScreen />}
{current === 'stock-reserve' && <ReserveStockScreen />}
{current === 'stock-movement-list' && <StockMovementListScreen />}
{current === 'stock-movement-detail' && <StockMovementDetailScreen />}
{current === 'stock-movement-record' && <StockMovementRecordScreen />}
{/* Produktion */}
{current === 'production-menu' && <ProductionMenu />}
{current === 'recipe-list' && <RecipeListScreen />}
@ -136,7 +144,9 @@ function ScreenRouter() {
{current === 'batch-plan' && <BatchPlanScreen />}
{current === 'batch-record-consumption' && <RecordConsumptionScreen />}
{current === 'batch-complete' && <CompleteBatchScreen />}
{current === 'production-order-list' && <ProductionOrderListScreen />}
{current === 'production-order-create' && <ProductionOrderCreateScreen />}
{current === 'production-order-detail' && <ProductionOrderDetailScreen />}
</MainLayout>
);
}

View file

@ -12,6 +12,7 @@ interface MenuItem {
const MENU_ITEMS: MenuItem[] = [
{ label: 'Lagerorte', screen: 'storage-location-list', description: 'Lagerorte verwalten (Kühlräume, Trockenlager, …)' },
{ 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' },
];

View file

@ -0,0 +1,81 @@
import React, { useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useStockMovements } from '../../hooks/useStockMovements.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { MOVEMENT_TYPE_LABELS, MOVEMENT_DIRECTION_LABELS } from '@effigenix/api-client';
import type { MovementType, MovementDirection } from '@effigenix/api-client';
const DIRECTION_COLORS: Record<string, string> = {
IN: 'green',
OUT: 'red',
};
export function StockMovementDetailScreen() {
const { params, back } = useNavigation();
const { movement, loading, error, fetchMovement, clearError } = useStockMovements();
const movementId = params.movementId ?? '';
useEffect(() => {
if (movementId) void fetchMovement(movementId);
}, [fetchMovement, movementId]);
useInput((_input, key) => {
if (key.backspace || key.escape) back();
});
if (loading && !movement) return <LoadingSpinner label="Lade Bewegung..." />;
if (!movement) {
return (
<Box flexDirection="column">
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Text color="red">Bewegung nicht gefunden.</Text>
</Box>
);
}
const typeLabel = MOVEMENT_TYPE_LABELS[movement.movementType as MovementType] ?? movement.movementType;
const dirLabel = MOVEMENT_DIRECTION_LABELS[movement.direction as MovementDirection] ?? movement.direction;
const dirColor = DIRECTION_COLORS[movement.direction] ?? 'white';
const dateStr = movement.performedAt
? new Date(movement.performedAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'medium' })
: '';
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Bestandsbewegung</Text>
{loading && <Text color="gray"> (aktualisiere...)</Text>}
</Box>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Box><Text color="gray">ID: </Text><Text>{movement.id}</Text></Box>
<Box><Text color="gray">Typ: </Text><Text>{typeLabel}</Text></Box>
<Box><Text color="gray">Richtung: </Text><Text color={dirColor}>{dirLabel}</Text></Box>
<Box><Text color="gray">Menge: </Text><Text>{movement.quantityAmount} {movement.quantityUnit}</Text></Box>
<Box><Text color="gray">Stock-ID: </Text><Text>{movement.stockId}</Text></Box>
<Box><Text color="gray">Artikel-ID: </Text><Text>{movement.articleId}</Text></Box>
<Box><Text color="gray">Chargen-Batch: </Text><Text>{movement.stockBatchId}</Text></Box>
<Box><Text color="gray">Chargen-Nr.: </Text><Text>{movement.batchId}</Text></Box>
<Box><Text color="gray">Chargen-Typ: </Text><Text>{movement.batchType}</Text></Box>
{movement.reason && (
<Box><Text color="gray">Grund: </Text><Text>{movement.reason}</Text></Box>
)}
{movement.referenceDocumentId && (
<Box><Text color="gray">Referenz-Dok.: </Text><Text>{movement.referenceDocumentId}</Text></Box>
)}
<Box><Text color="gray">Durchgeführt von:</Text><Text> {movement.performedBy}</Text></Box>
<Box><Text color="gray">Zeitpunkt: </Text><Text>{dateStr}</Text></Box>
</Box>
<Box marginTop={1}>
<Text color="gray" dimColor>Backspace Zurück</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,127 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useStockMovements } from '../../hooks/useStockMovements.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { MOVEMENT_TYPE_LABELS, MOVEMENT_DIRECTION_LABELS } from '@effigenix/api-client';
import type { MovementType, MovementDirection, StockMovementFilter } from '@effigenix/api-client';
const DIRECTION_COLORS: Record<string, string> = {
IN: 'green',
OUT: 'red',
};
const TYPE_FILTER_OPTIONS: { key: string; label: string; value: string | undefined }[] = [
{ key: 'a', label: 'ALLE', value: undefined },
{ key: '1', label: 'Wareneingang', value: 'GOODS_RECEIPT' },
{ key: '2', label: 'Prod.-Ausstoß', value: 'PRODUCTION_OUTPUT' },
{ key: '3', label: 'Prod.-Verbrauch', value: 'PRODUCTION_CONSUMPTION' },
{ key: '4', label: 'Verkauf', value: 'SALE' },
{ key: '5', label: 'Retoure', value: 'RETURN' },
{ key: '6', label: 'Ausschuss', value: 'WASTE' },
{ key: '7', label: 'Korrektur', value: 'ADJUSTMENT' },
{ key: '8', label: 'Umlagerung', value: 'INTER_BRANCH_TRANSFER' },
];
export function StockMovementListScreen() {
const { navigate, back, params } = useNavigation();
const { movements, loading, error, fetchMovements, clearError } = useStockMovements();
const [selectedIndex, setSelectedIndex] = useState(0);
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const stockId = params.stockId;
useEffect(() => {
const filter: StockMovementFilter = {};
if (stockId) filter.stockId = stockId;
if (typeFilter) filter.movementType = typeFilter;
void fetchMovements(filter);
}, [fetchMovements, stockId, typeFilter]);
useInput((input, key) => {
if (loading) return;
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex((i) => Math.min(movements.length - 1, i + 1));
if (key.return && movements.length > 0) {
const movement = movements[selectedIndex];
if (movement) navigate('stock-movement-detail', { movementId: movement.id });
}
if (input === 'n') navigate('stock-movement-record', stockId ? { stockId } : {});
if (input === 'r') {
const filter: StockMovementFilter = {};
if (stockId) filter.stockId = stockId;
if (typeFilter) filter.movementType = typeFilter;
void fetchMovements(filter);
}
if (key.backspace || key.escape) back();
for (const opt of TYPE_FILTER_OPTIONS) {
if (input === opt.key) {
setTypeFilter(opt.value);
setSelectedIndex(0);
break;
}
}
});
const activeFilterLabel = TYPE_FILTER_OPTIONS.find((f) => f.value === typeFilter)?.label ?? 'ALLE';
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Bestandsbewegungen</Text>
<Text color="gray" dimColor>({movements.length})</Text>
<Text color="gray" dimColor>Filter: </Text>
<Text color="yellow" bold>{activeFilterLabel}</Text>
</Box>
{loading && <LoadingSpinner label="Lade Bewegungen..." />}
{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>{' Typ'.padEnd(22)}</Text>
<Text color="gray" bold>{'Richtung'.padEnd(10)}</Text>
<Text color="gray" bold>{'Menge'.padEnd(16)}</Text>
<Text color="gray" bold>{'Charge'.padEnd(16)}</Text>
<Text color="gray" bold>Zeitpunkt</Text>
</Box>
{movements.length === 0 && (
<Box paddingX={1} paddingY={1}>
<Text color="gray" dimColor>Keine Bewegungen gefunden.</Text>
</Box>
)}
{movements.map((m, index) => {
const isSelected = index === selectedIndex;
const textColor = isSelected ? 'cyan' : 'white';
const typeLabel = MOVEMENT_TYPE_LABELS[m.movementType as MovementType] ?? m.movementType;
const dirLabel = MOVEMENT_DIRECTION_LABELS[m.direction as MovementDirection] ?? m.direction;
const dirColor = isSelected ? 'cyan' : (DIRECTION_COLORS[m.direction] ?? 'white');
const qty = `${m.quantityAmount} ${m.quantityUnit}`;
const dateStr = m.performedAt ? new Date(m.performedAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '';
return (
<Box key={m.id} paddingX={1}>
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
<Text color={textColor}>{typeLabel.substring(0, 18).padEnd(19)}</Text>
<Text color={dirColor}>{dirLabel.padEnd(10)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{qty.substring(0, 14).padEnd(16)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{(m.batchId ?? '').substring(0, 14).padEnd(16)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{dateStr}</Text>
</Box>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · [r] Aktualisieren · [a] Alle [1-8] Typ-Filter · Backspace Zurück
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,325 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useStockMovements } from '../../hooks/useStockMovements.js';
import { useStocks } from '../../hooks/useStocks.js';
import { FormInput } from '../shared/FormInput.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { MOVEMENT_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from '@effigenix/api-client';
import type { MovementType, UoM, StockDTO, StockBatchDTO, RecordStockMovementRequest } from '@effigenix/api-client';
const MOVEMENT_TYPES: MovementType[] = [
'GOODS_RECEIPT', 'PRODUCTION_OUTPUT', 'PRODUCTION_CONSUMPTION',
'SALE', 'RETURN', 'WASTE', 'ADJUSTMENT', 'INTER_BRANCH_TRANSFER',
];
type Field = 'stock' | 'batch' | 'movementType' | 'direction' | 'quantity' | 'unit' | 'reason' | 'referenceDocumentId';
const FIELD_LABELS: Record<Field, string> = {
stock: 'Bestand (↑↓ auswählen)',
batch: 'Charge (↑↓ auswählen)',
movementType: 'Bewegungstyp (←→ wechseln)',
direction: 'Richtung (←→ wechseln)',
quantity: 'Menge *',
unit: 'Einheit (←→ wechseln)',
reason: 'Grund (bei Ausschuss/Korrektur)',
referenceDocumentId: 'Referenz-Dok. (bei Umlagerung)',
};
const DIRECTIONS = ['IN', 'OUT'] as const;
const DIRECTION_LABELS: Record<string, string> = { IN: 'Eingang', OUT: 'Ausgang' };
function needsDirection(type: MovementType): boolean {
return type === 'ADJUSTMENT';
}
function needsReason(type: MovementType): boolean {
return type === 'WASTE' || type === 'ADJUSTMENT';
}
function needsReference(type: MovementType): boolean {
return type === 'INTER_BRANCH_TRANSFER';
}
export function StockMovementRecordScreen() {
const { back, params } = useNavigation();
const { recordMovement, loading, error, clearError } = useStockMovements();
const { stocks, fetchStocks } = useStocks();
const preselectedStockId = params.stockId;
const [stockIdx, setStockIdx] = useState(0);
const [batchIdx, setBatchIdx] = useState(0);
const [typeIdx, setTypeIdx] = useState(0);
const [dirIdx, setDirIdx] = useState(0);
const [quantity, setQuantity] = useState('');
const [uomIdx, setUomIdx] = useState(0);
const [reason, setReason] = useState('');
const [refDocId, setRefDocId] = useState('');
const [activeField, setActiveField] = useState<Field>(preselectedStockId ? 'batch' : 'stock');
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
const [success, setSuccess] = useState(false);
useEffect(() => {
void fetchStocks();
}, [fetchStocks]);
useEffect(() => {
if (preselectedStockId && stocks.length > 0) {
const idx = stocks.findIndex((s) => s.id === preselectedStockId);
if (idx >= 0) setStockIdx(idx);
}
}, [preselectedStockId, stocks]);
const selectedStock: StockDTO | undefined = stocks[stockIdx];
const batches: StockBatchDTO[] = selectedStock?.batches ?? [];
const selectedBatch: StockBatchDTO | undefined = batches[batchIdx];
const selectedType = MOVEMENT_TYPES[typeIdx]!;
const getActiveFields = (): Field[] => {
const fields: Field[] = ['stock', 'batch', 'movementType'];
if (needsDirection(selectedType)) fields.push('direction');
fields.push('quantity', 'unit');
if (needsReason(selectedType)) fields.push('reason');
if (needsReference(selectedType)) fields.push('referenceDocumentId');
return fields;
};
const handleSubmit = async () => {
const errors: Partial<Record<Field, string>> = {};
if (!selectedStock) errors.stock = 'Bestand auswählen.';
if (!selectedBatch) errors.batch = 'Charge auswählen.';
if (!quantity.trim()) errors.quantity = 'Menge ist erforderlich.';
if (needsDirection(selectedType) && !DIRECTIONS[dirIdx]) errors.direction = 'Richtung auswählen.';
if (needsReason(selectedType) && !reason.trim()) errors.reason = 'Grund ist erforderlich.';
if (needsReference(selectedType) && !refDocId.trim()) errors.referenceDocumentId = 'Referenz ist erforderlich.';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const request: Record<string, string> = {
stockId: selectedStock!.id,
articleId: selectedStock!.articleId,
stockBatchId: selectedBatch!.id!,
batchId: selectedBatch!.batchId!,
batchType: selectedBatch!.batchType!,
movementType: selectedType,
quantityAmount: quantity.trim(),
quantityUnit: UOM_VALUES[uomIdx] as string,
};
if (needsDirection(selectedType)) request.direction = DIRECTIONS[dirIdx];
if (needsReason(selectedType) && reason.trim()) request.reason = reason.trim();
if (needsReference(selectedType) && refDocId.trim()) request.referenceDocumentId = refDocId.trim();
const result = await recordMovement(request as unknown as RecordStockMovementRequest);
if (result) setSuccess(true);
};
const activeFields = getActiveFields();
const goNextField = () => {
const idx = activeFields.indexOf(activeField);
if (idx < activeFields.length - 1) {
setActiveField(activeFields[idx + 1]!);
} else {
void handleSubmit();
}
};
const goPrevField = () => {
const idx = activeFields.indexOf(activeField);
if (idx > 0) setActiveField(activeFields[idx - 1]!);
};
useInput((_input, key) => {
if (loading) return;
if (success) {
if (key.return || key.escape) back();
return;
}
if (activeField === 'stock') {
if (key.upArrow) setStockIdx((i) => Math.max(0, i - 1));
if (key.downArrow) setStockIdx((i) => Math.min(stocks.length - 1, i + 1));
if (key.return || key.tab) { setBatchIdx(0); goNextField(); }
if (key.escape) back();
return;
}
if (activeField === 'batch') {
if (key.upArrow) setBatchIdx((i) => Math.max(0, i - 1));
if (key.downArrow) setBatchIdx((i) => Math.min(batches.length - 1, i + 1));
if (key.return || key.tab) goNextField();
if (key.escape) goPrevField();
return;
}
if (activeField === 'movementType') {
if (key.leftArrow || key.rightArrow) {
const dir = key.rightArrow ? 1 : -1;
setTypeIdx((i) => (i + dir + MOVEMENT_TYPES.length) % MOVEMENT_TYPES.length);
return;
}
if (key.return || key.tab) goNextField();
if (key.escape) goPrevField();
return;
}
if (activeField === 'direction') {
if (key.leftArrow || key.rightArrow) {
setDirIdx((i) => (i + 1) % DIRECTIONS.length);
return;
}
if (key.return || key.tab) goNextField();
if (key.escape) goPrevField();
return;
}
if (activeField === 'unit') {
if (key.leftArrow || key.rightArrow) {
const dir = key.rightArrow ? 1 : -1;
setUomIdx((i) => (i + dir + UOM_VALUES.length) % UOM_VALUES.length);
return;
}
if (key.return || key.tab) goNextField();
if (key.escape) goPrevField();
return;
}
// Text-Felder
if (key.tab || key.downArrow) goNextField();
if (key.upArrow) goPrevField();
if (key.escape) goPrevField();
});
if (loading) return <LoadingSpinner label="Bewegung wird erfasst..." />;
if (success) {
return (
<Box flexDirection="column" gap={1} paddingY={1}>
<Text color="green" bold>Bestandsbewegung erfolgreich erfasst!</Text>
<Text color="gray" dimColor>Enter/Escape zum Zurückkehren</Text>
</Box>
);
}
const typeLabel = MOVEMENT_TYPE_LABELS[selectedType];
const uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx];
const dirLabel = DIRECTION_LABELS[DIRECTIONS[dirIdx]] ?? DIRECTIONS[dirIdx];
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Bestandsbewegung erfassen</Text>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" gap={1} width={60}>
{/* Stock selector */}
<Box flexDirection="column">
<Text color={activeField === 'stock' ? 'cyan' : 'gray'}>{FIELD_LABELS.stock}</Text>
{fieldErrors.stock && <Text color="red">{fieldErrors.stock}</Text>}
<Box flexDirection="column" borderStyle="round" borderColor={activeField === 'stock' ? 'cyan' : 'gray'} paddingX={1}>
{stocks.length === 0 ? (
<Text color="gray" dimColor>Keine Bestände gefunden.</Text>
) : (
stocks.slice(Math.max(0, stockIdx - 3), stockIdx + 4).map((s, i) => {
const actualIdx = Math.max(0, stockIdx - 3) + i;
const isSelected = actualIdx === stockIdx;
return (
<Text key={s.id} color={isSelected ? 'cyan' : 'white'}>
{isSelected ? '▶ ' : ' '}{s.articleId.substring(0, 12)} @ {s.storageLocationId.substring(0, 12)}
</Text>
);
})
)}
</Box>
</Box>
{/* Batch selector */}
<Box flexDirection="column">
<Text color={activeField === 'batch' ? 'cyan' : 'gray'}>{FIELD_LABELS.batch}</Text>
{fieldErrors.batch && <Text color="red">{fieldErrors.batch}</Text>}
<Box flexDirection="column" borderStyle="round" borderColor={activeField === 'batch' ? 'cyan' : 'gray'} paddingX={1}>
{batches.length === 0 ? (
<Text color="gray" dimColor>Keine Chargen in diesem Bestand.</Text>
) : (
batches.slice(Math.max(0, batchIdx - 3), batchIdx + 4).map((b, i) => {
const actualIdx = Math.max(0, batchIdx - 3) + i;
const isSelected = actualIdx === batchIdx;
return (
<Text key={b.id} color={isSelected ? 'cyan' : 'white'}>
{isSelected ? '▶ ' : ' '}{b.batchId} ({b.batchType}) {b.quantityAmount} {b.quantityUnit}
</Text>
);
})
)}
</Box>
</Box>
{/* Movement Type */}
<Box flexDirection="column">
<Text color={activeField === 'movementType' ? 'cyan' : 'gray'}>
{FIELD_LABELS.movementType}: <Text bold color="white">{activeField === 'movementType' ? `< ${typeLabel} >` : typeLabel}</Text>
</Text>
</Box>
{/* Direction (only for ADJUSTMENT) */}
{needsDirection(selectedType) && (
<Box flexDirection="column">
<Text color={activeField === 'direction' ? 'cyan' : 'gray'}>
{FIELD_LABELS.direction}: <Text bold color="white">{activeField === 'direction' ? `< ${dirLabel} >` : dirLabel}</Text>
</Text>
{fieldErrors.direction && <Text color="red">{fieldErrors.direction}</Text>}
</Box>
)}
{/* Quantity */}
<FormInput
label={FIELD_LABELS.quantity}
value={quantity}
onChange={setQuantity}
onSubmit={() => goNextField()}
focus={activeField === 'quantity'}
{...(fieldErrors.quantity ? { error: fieldErrors.quantity } : {})}
/>
{/* Unit */}
<Box flexDirection="column">
<Text color={activeField === 'unit' ? 'cyan' : 'gray'}>
{FIELD_LABELS.unit}: <Text bold color="white">{activeField === 'unit' ? `< ${uomLabel} >` : uomLabel}</Text>
</Text>
</Box>
{/* Reason (for WASTE/ADJUSTMENT) */}
{needsReason(selectedType) && (
<FormInput
label={FIELD_LABELS.reason}
value={reason}
onChange={setReason}
onSubmit={() => goNextField()}
focus={activeField === 'reason'}
{...(fieldErrors.reason ? { error: fieldErrors.reason } : {})}
/>
)}
{/* Reference Document ID (for INTER_BRANCH_TRANSFER) */}
{needsReference(selectedType) && (
<FormInput
label={FIELD_LABELS.referenceDocumentId}
value={refDocId}
onChange={setRefDocId}
onSubmit={() => void handleSubmit()}
focus={activeField === 'referenceDocumentId'}
{...(fieldErrors.referenceDocumentId ? { error: fieldErrors.referenceDocumentId } : {})}
/>
)}
</Box>
<Box marginTop={1}>
<Text color="gray" dimColor>
Tab/ Feld wechseln · Typ/Einheit/Richtung · Enter bestätigen/speichern · Escape Zurück
</Text>
</Box>
</Box>
);
}

View file

@ -12,7 +12,7 @@ interface MenuItem {
const MENU_ITEMS: MenuItem[] = [
{ label: 'Rezepte', screen: 'recipe-list', description: 'Rezepte anlegen und verwalten' },
{ label: 'Chargen', screen: 'batch-list', description: 'Produktionschargen planen, starten und abschließen' },
{ label: 'Produktionsaufträge', screen: 'production-order-create', description: 'Produktionsauftrag anlegen' },
{ label: 'Produktionsaufträge', screen: 'production-order-list', description: 'Produktionsaufträge verwalten, freigeben und starten' },
];
export function ProductionMenu() {

View file

@ -0,0 +1,185 @@
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 { useProductionOrders } from '../../hooks/useProductionOrders.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { SuccessDisplay } from '../shared/SuccessDisplay.js';
import { PRODUCTION_ORDER_STATUS_LABELS, PRIORITY_LABELS } from '@effigenix/api-client';
import type { ProductionOrderStatus, Priority } from '@effigenix/api-client';
const STATUS_COLORS: Record<string, string> = {
CREATED: 'gray',
RELEASED: 'yellow',
IN_PRODUCTION: 'blue',
COMPLETED: 'green',
CANCELLED: 'red',
};
type Mode = 'view' | 'menu' | 'start-batch-input';
export function ProductionOrderDetailScreen() {
const { params, back } = useNavigation();
const {
productionOrder, loading, error,
fetchProductionOrder, releaseProductionOrder, startProductionOrder, clearError,
} = useProductionOrders();
const [mode, setMode] = useState<Mode>('view');
const [menuIndex, setMenuIndex] = useState(0);
const [batchId, setBatchId] = useState('');
const [success, setSuccess] = useState<string | null>(null);
const orderId = params.orderId ?? '';
useEffect(() => {
if (orderId) void fetchProductionOrder(orderId);
}, [fetchProductionOrder, orderId]);
const getMenuItems = () => {
const items: { label: string; action: string }[] = [];
const status = productionOrder?.status;
if (status === 'CREATED') {
items.push({ label: 'Freigeben', action: 'release' });
}
if (status === 'RELEASED') {
items.push({ label: 'Produktion starten', action: 'start' });
}
return items;
};
const menuItems = getMenuItems();
const handleRelease = async () => {
const result = await releaseProductionOrder(orderId);
if (result) {
setSuccess('Produktionsauftrag freigegeben.');
setMode('view');
}
};
const handleStart = async () => {
if (!batchId.trim()) return;
const result = await startProductionOrder(orderId, { batchId: batchId.trim() });
if (result) {
setSuccess('Produktion gestartet.');
setMode('view');
setBatchId('');
}
};
useInput((_input, key) => {
if (loading) return;
if (mode === 'start-batch-input') {
if (key.escape) setMode('menu');
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 === 'release') void handleRelease();
if (action === 'start') {
setMode('start-batch-input');
setBatchId('');
}
}
if (key.escape) setMode('view');
return;
}
// view mode
if (_input === 'm' && menuItems.length > 0) {
setMode('menu');
setMenuIndex(0);
}
if (key.backspace || key.escape) back();
});
if (loading && !productionOrder) return <LoadingSpinner label="Lade Auftrag..." />;
if (!productionOrder) {
return (
<Box flexDirection="column">
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Text color="red">Produktionsauftrag nicht gefunden.</Text>
</Box>
);
}
const statusLabel = PRODUCTION_ORDER_STATUS_LABELS[(productionOrder.status ?? '') as ProductionOrderStatus] ?? productionOrder.status;
const statusColor = STATUS_COLORS[productionOrder.status ?? ''] ?? 'white';
const prioLabel = PRIORITY_LABELS[(productionOrder.priority ?? '') as Priority] ?? productionOrder.priority;
const createdAt = productionOrder.createdAt
? new Date(productionOrder.createdAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
: '';
const updatedAt = productionOrder.updatedAt
? new Date(productionOrder.updatedAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
: '';
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Produktionsauftrag</Text>
{loading && <Text color="gray"> (aktualisiere...)</Text>}
</Box>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
{success && <SuccessDisplay message={success} onDismiss={() => setSuccess(null)} />}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Box><Text color="gray">ID: </Text><Text>{productionOrder.id}</Text></Box>
<Box><Text color="gray">Rezept-ID: </Text><Text>{productionOrder.recipeId}</Text></Box>
<Box><Text color="gray">Status: </Text><Text color={statusColor}>{statusLabel}</Text></Box>
<Box><Text color="gray">Menge: </Text><Text>{productionOrder.plannedQuantity} {productionOrder.plannedQuantityUnit}</Text></Box>
<Box><Text color="gray">Geplant am: </Text><Text>{productionOrder.plannedDate}</Text></Box>
<Box><Text color="gray">Priorität: </Text><Text>{prioLabel}</Text></Box>
{productionOrder.batchId && (
<Box><Text color="gray">Chargen-ID: </Text><Text>{productionOrder.batchId}</Text></Box>
)}
{productionOrder.notes && (
<Box><Text color="gray">Notizen: </Text><Text>{productionOrder.notes}</Text></Box>
)}
<Box><Text color="gray">Erstellt: </Text><Text>{createdAt}</Text></Box>
<Box><Text color="gray">Aktualisiert:</Text><Text> {updatedAt}</Text></Box>
</Box>
{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>
)}
{mode === 'start-batch-input' && (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>Chargen-ID eingeben:</Text>
<Box>
<Text color="gray"> </Text>
<TextInput
value={batchId}
onChange={setBatchId}
onSubmit={() => void handleStart()}
focus={true}
/>
</Box>
<Text color="gray" dimColor>Enter bestätigen · Escape abbrechen</Text>
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
{menuItems.length > 0 ? '[m] Aktionsmenü · ' : ''}Backspace Zurück
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useProductionOrders } from '../../hooks/useProductionOrders.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { PRODUCTION_ORDER_STATUS_LABELS, PRIORITY_LABELS } from '@effigenix/api-client';
import type { ProductionOrderStatus, Priority } from '@effigenix/api-client';
const STATUS_COLORS: Record<string, string> = {
CREATED: 'gray',
RELEASED: 'yellow',
IN_PRODUCTION: 'blue',
COMPLETED: 'green',
CANCELLED: 'red',
};
export function ProductionOrderListScreen() {
const { navigate, back } = useNavigation();
const { productionOrders, loading, error, fetchProductionOrders, clearError } = useProductionOrders();
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
void fetchProductionOrders();
}, [fetchProductionOrders]);
useInput((input, key) => {
if (loading) return;
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex((i) => Math.min(productionOrders.length - 1, i + 1));
if (key.return && productionOrders.length > 0) {
const order = productionOrders[selectedIndex];
if (order?.id) navigate('production-order-detail', { orderId: order.id });
}
if (input === 'n') navigate('production-order-create');
if (input === 'r') void fetchProductionOrders();
if (key.backspace || key.escape) back();
});
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Produktionsaufträge</Text>
<Text color="gray" dimColor>({productionOrders.length})</Text>
</Box>
{loading && <LoadingSpinner label="Lade Aufträge..." />}
{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>{' Rezept-ID'.padEnd(16)}</Text>
<Text color="gray" bold>{'Menge'.padEnd(16)}</Text>
<Text color="gray" bold>{'Datum'.padEnd(14)}</Text>
<Text color="gray" bold>{'Priorität'.padEnd(12)}</Text>
<Text color="gray" bold>Status</Text>
</Box>
{productionOrders.length === 0 && (
<Box paddingX={1} paddingY={1}>
<Text color="gray" dimColor>Keine Produktionsaufträge gefunden.</Text>
</Box>
)}
{productionOrders.map((order, index) => {
const isSelected = index === selectedIndex;
const textColor = isSelected ? 'cyan' : 'white';
const statusColor = STATUS_COLORS[order.status ?? ''] ?? 'white';
const statusLabel = PRODUCTION_ORDER_STATUS_LABELS[(order.status ?? '') as ProductionOrderStatus] ?? order.status;
const prioLabel = PRIORITY_LABELS[(order.priority ?? '') as Priority] ?? order.priority;
const qty = `${order.plannedQuantity ?? ''} ${order.plannedQuantityUnit ?? ''}`;
return (
<Box key={order.id} paddingX={1}>
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
<Text color={textColor}>{(order.recipeId ?? '').substring(0, 12).padEnd(13)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{qty.substring(0, 14).padEnd(16)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{(order.plannedDate ?? '').padEnd(14)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{(prioLabel ?? '').padEnd(12)}</Text>
<Text color={isSelected ? 'cyan' : statusColor}>{statusLabel}</Text>
</Box>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · [r] Aktualisieren · Backspace Zurück
</Text>
</Box>
</Box>
);
}

View file

@ -1,8 +1,9 @@
import { useState, useCallback } from 'react';
import type { ProductionOrderDTO, CreateProductionOrderRequest } from '@effigenix/api-client';
import type { ProductionOrderDTO, CreateProductionOrderRequest, StartProductionOrderRequest } from '@effigenix/api-client';
import { client } from '../utils/api-client.js';
interface ProductionOrdersState {
productionOrders: ProductionOrderDTO[];
productionOrder: ProductionOrderDTO | null;
loading: boolean;
error: string | null;
@ -14,16 +15,37 @@ function errorMessage(err: unknown): string {
export function useProductionOrders() {
const [state, setState] = useState<ProductionOrdersState>({
productionOrders: [],
productionOrder: null,
loading: false,
error: null,
});
const fetchProductionOrders = useCallback(async () => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const productionOrders = await client.productionOrders.list();
setState((s) => ({ ...s, productionOrders, loading: false, error: null }));
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
}
}, []);
const fetchProductionOrder = useCallback(async (id: string) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const productionOrder = await client.productionOrders.getById(id);
setState((s) => ({ ...s, productionOrder, loading: false, error: null }));
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
}
}, []);
const createProductionOrder = useCallback(async (request: CreateProductionOrderRequest) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const productionOrder = await client.productionOrders.create(request);
setState({ productionOrder, loading: false, error: null });
setState((s) => ({ ...s, productionOrder, loading: false, error: null }));
return productionOrder;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
@ -35,7 +57,19 @@ export function useProductionOrders() {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const productionOrder = await client.productionOrders.release(id);
setState({ productionOrder, loading: false, error: null });
setState((s) => ({ ...s, productionOrder, loading: false, error: null }));
return productionOrder;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
return null;
}
}, []);
const startProductionOrder = useCallback(async (id: string, request: StartProductionOrderRequest) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const productionOrder = await client.productionOrders.start(id, request);
setState((s) => ({ ...s, productionOrder, loading: false, error: null }));
return productionOrder;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
@ -49,8 +83,11 @@ export function useProductionOrders() {
return {
...state,
fetchProductionOrders,
fetchProductionOrder,
createProductionOrder,
releaseProductionOrder,
startProductionOrder,
clearError,
};
}

View file

@ -0,0 +1,67 @@
import { useState, useCallback } from 'react';
import type { StockMovementDTO, RecordStockMovementRequest, StockMovementFilter } from '@effigenix/api-client';
import { client } from '../utils/api-client.js';
interface StockMovementsState {
movements: StockMovementDTO[];
movement: StockMovementDTO | null;
loading: boolean;
error: string | null;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function useStockMovements() {
const [state, setState] = useState<StockMovementsState>({
movements: [],
movement: null,
loading: false,
error: null,
});
const fetchMovements = useCallback(async (filter?: StockMovementFilter) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const movements = await client.stockMovements.list(filter);
setState((s) => ({ ...s, movements, loading: false, error: null }));
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
}
}, []);
const fetchMovement = useCallback(async (id: string) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const movement = await client.stockMovements.getById(id);
setState((s) => ({ ...s, movement, loading: false, error: null }));
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
}
}, []);
const recordMovement = useCallback(async (request: RecordStockMovementRequest) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const movement = await client.stockMovements.record(request);
setState((s) => ({ ...s, movement, loading: false, error: null }));
return movement;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
return null;
}
}, []);
const clearError = useCallback(() => {
setState((s) => ({ ...s, error: null }));
}, []);
return {
...state,
fetchMovements,
fetchMovement,
recordMovement,
clearError,
};
}

View file

@ -39,6 +39,9 @@ export type Screen =
| 'stock-list'
| 'stock-detail'
| 'stock-create'
| 'stock-movement-list'
| 'stock-movement-detail'
| 'stock-movement-record'
// Produktion
| 'production-menu'
| 'recipe-list'
@ -51,7 +54,9 @@ export type Screen =
| 'batch-plan'
| 'batch-record-consumption'
| 'batch-complete'
| 'production-order-list'
| 'production-order-create'
| 'production-order-detail'
| 'stock-reserve';
interface NavigationState {