mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 11:59: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:
parent
0474b5fa93
commit
7d721f9ef0
18 changed files with 1279 additions and 9 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
67
frontend/apps/cli/src/hooks/useStockMovements.ts
Normal file
67
frontend/apps/cli/src/hooks/useStockMovements.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue