mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +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 { StorageLocationEditScreen } from './components/inventory/StorageLocationEditScreen.js';
|
||||||
import { StockBatchEntryScreen } from './components/inventory/StockBatchEntryScreen.js';
|
import { StockBatchEntryScreen } from './components/inventory/StockBatchEntryScreen.js';
|
||||||
import { AddBatchScreen } from './components/inventory/AddBatchScreen.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
|
// Produktion
|
||||||
import { ProductionMenu } from './components/production/ProductionMenu.js';
|
import { ProductionMenu } from './components/production/ProductionMenu.js';
|
||||||
import { RecipeListScreen } from './components/production/RecipeListScreen.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 { BatchPlanScreen } from './components/production/BatchPlanScreen.js';
|
||||||
import { RecordConsumptionScreen } from './components/production/RecordConsumptionScreen.js';
|
import { RecordConsumptionScreen } from './components/production/RecordConsumptionScreen.js';
|
||||||
import { CompleteBatchScreen } from './components/production/CompleteBatchScreen.js';
|
import { CompleteBatchScreen } from './components/production/CompleteBatchScreen.js';
|
||||||
|
import { ProductionOrderListScreen } from './components/production/ProductionOrderListScreen.js';
|
||||||
import { ProductionOrderCreateScreen } from './components/production/ProductionOrderCreateScreen.js';
|
import { ProductionOrderCreateScreen } from './components/production/ProductionOrderCreateScreen.js';
|
||||||
|
import { ProductionOrderDetailScreen } from './components/production/ProductionOrderDetailScreen.js';
|
||||||
import { StockListScreen } from './components/inventory/StockListScreen.js';
|
import { StockListScreen } from './components/inventory/StockListScreen.js';
|
||||||
import { StockDetailScreen } from './components/inventory/StockDetailScreen.js';
|
import { StockDetailScreen } from './components/inventory/StockDetailScreen.js';
|
||||||
import { StockCreateScreen } from './components/inventory/StockCreateScreen.js';
|
import { StockCreateScreen } from './components/inventory/StockCreateScreen.js';
|
||||||
|
|
@ -124,6 +129,9 @@ function ScreenRouter() {
|
||||||
{current === 'stock-detail' && <StockDetailScreen />}
|
{current === 'stock-detail' && <StockDetailScreen />}
|
||||||
{current === 'stock-create' && <StockCreateScreen />}
|
{current === 'stock-create' && <StockCreateScreen />}
|
||||||
{current === 'stock-reserve' && <ReserveStockScreen />}
|
{current === 'stock-reserve' && <ReserveStockScreen />}
|
||||||
|
{current === 'stock-movement-list' && <StockMovementListScreen />}
|
||||||
|
{current === 'stock-movement-detail' && <StockMovementDetailScreen />}
|
||||||
|
{current === 'stock-movement-record' && <StockMovementRecordScreen />}
|
||||||
{/* Produktion */}
|
{/* Produktion */}
|
||||||
{current === 'production-menu' && <ProductionMenu />}
|
{current === 'production-menu' && <ProductionMenu />}
|
||||||
{current === 'recipe-list' && <RecipeListScreen />}
|
{current === 'recipe-list' && <RecipeListScreen />}
|
||||||
|
|
@ -136,7 +144,9 @@ function ScreenRouter() {
|
||||||
{current === 'batch-plan' && <BatchPlanScreen />}
|
{current === 'batch-plan' && <BatchPlanScreen />}
|
||||||
{current === 'batch-record-consumption' && <RecordConsumptionScreen />}
|
{current === 'batch-record-consumption' && <RecordConsumptionScreen />}
|
||||||
{current === 'batch-complete' && <CompleteBatchScreen />}
|
{current === 'batch-complete' && <CompleteBatchScreen />}
|
||||||
|
{current === 'production-order-list' && <ProductionOrderListScreen />}
|
||||||
{current === 'production-order-create' && <ProductionOrderCreateScreen />}
|
{current === 'production-order-create' && <ProductionOrderCreateScreen />}
|
||||||
|
{current === 'production-order-detail' && <ProductionOrderDetailScreen />}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ interface MenuItem {
|
||||||
const MENU_ITEMS: MenuItem[] = [
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
{ label: 'Lagerorte', screen: 'storage-location-list', description: 'Lagerorte verwalten (Kühlräume, Trockenlager, …)' },
|
{ 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: '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: '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[] = [
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
{ label: 'Rezepte', screen: 'recipe-list', description: 'Rezepte anlegen und verwalten' },
|
{ label: 'Rezepte', screen: 'recipe-list', description: 'Rezepte anlegen und verwalten' },
|
||||||
{ label: 'Chargen', screen: 'batch-list', description: 'Produktionschargen planen, starten und abschließen' },
|
{ 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() {
|
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 { 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';
|
import { client } from '../utils/api-client.js';
|
||||||
|
|
||||||
interface ProductionOrdersState {
|
interface ProductionOrdersState {
|
||||||
|
productionOrders: ProductionOrderDTO[];
|
||||||
productionOrder: ProductionOrderDTO | null;
|
productionOrder: ProductionOrderDTO | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
@ -14,16 +15,37 @@ function errorMessage(err: unknown): string {
|
||||||
|
|
||||||
export function useProductionOrders() {
|
export function useProductionOrders() {
|
||||||
const [state, setState] = useState<ProductionOrdersState>({
|
const [state, setState] = useState<ProductionOrdersState>({
|
||||||
|
productionOrders: [],
|
||||||
productionOrder: null,
|
productionOrder: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
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) => {
|
const createProductionOrder = useCallback(async (request: CreateProductionOrderRequest) => {
|
||||||
setState((s) => ({ ...s, loading: true, error: null }));
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
try {
|
try {
|
||||||
const productionOrder = await client.productionOrders.create(request);
|
const productionOrder = await client.productionOrders.create(request);
|
||||||
setState({ productionOrder, loading: false, error: null });
|
setState((s) => ({ ...s, productionOrder, loading: false, error: null }));
|
||||||
return productionOrder;
|
return productionOrder;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||||
|
|
@ -35,7 +57,19 @@ export function useProductionOrders() {
|
||||||
setState((s) => ({ ...s, loading: true, error: null }));
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
try {
|
try {
|
||||||
const productionOrder = await client.productionOrders.release(id);
|
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;
|
return productionOrder;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||||
|
|
@ -49,8 +83,11 @@ export function useProductionOrders() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
fetchProductionOrders,
|
||||||
|
fetchProductionOrder,
|
||||||
createProductionOrder,
|
createProductionOrder,
|
||||||
releaseProductionOrder,
|
releaseProductionOrder,
|
||||||
|
startProductionOrder,
|
||||||
clearError,
|
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-list'
|
||||||
| 'stock-detail'
|
| 'stock-detail'
|
||||||
| 'stock-create'
|
| 'stock-create'
|
||||||
|
| 'stock-movement-list'
|
||||||
|
| 'stock-movement-detail'
|
||||||
|
| 'stock-movement-record'
|
||||||
// Produktion
|
// Produktion
|
||||||
| 'production-menu'
|
| 'production-menu'
|
||||||
| 'recipe-list'
|
| 'recipe-list'
|
||||||
|
|
@ -51,7 +54,9 @@ export type Screen =
|
||||||
| 'batch-plan'
|
| 'batch-plan'
|
||||||
| 'batch-record-consumption'
|
| 'batch-record-consumption'
|
||||||
| 'batch-complete'
|
| 'batch-complete'
|
||||||
|
| 'production-order-list'
|
||||||
| 'production-order-create'
|
| 'production-order-create'
|
||||||
|
| 'production-order-detail'
|
||||||
| 'stock-reserve';
|
| 'stock-reserve';
|
||||||
|
|
||||||
interface NavigationState {
|
interface NavigationState {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -27,6 +27,7 @@ export { createRecipesResource } from './resources/recipes.js';
|
||||||
export { createBatchesResource } from './resources/batches.js';
|
export { createBatchesResource } from './resources/batches.js';
|
||||||
export { createProductionOrdersResource } from './resources/production-orders.js';
|
export { createProductionOrdersResource } from './resources/production-orders.js';
|
||||||
export { createStocksResource } from './resources/stocks.js';
|
export { createStocksResource } from './resources/stocks.js';
|
||||||
|
export { createStockMovementsResource } from './resources/stock-movements.js';
|
||||||
export { createCountriesResource } from './resources/countries.js';
|
export { createCountriesResource } from './resources/countries.js';
|
||||||
export {
|
export {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
|
@ -112,6 +113,9 @@ export type {
|
||||||
ReservationDTO,
|
ReservationDTO,
|
||||||
StockBatchAllocationDTO,
|
StockBatchAllocationDTO,
|
||||||
ReserveStockRequest,
|
ReserveStockRequest,
|
||||||
|
StockMovementDTO,
|
||||||
|
RecordStockMovementRequest,
|
||||||
|
StartProductionOrderRequest,
|
||||||
CountryDTO,
|
CountryDTO,
|
||||||
} from '@effigenix/types';
|
} from '@effigenix/types';
|
||||||
|
|
||||||
|
|
@ -140,9 +144,11 @@ export type { RecipesResource, RecipeType, RecipeStatus, UoM } from './resources
|
||||||
export { RECIPE_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from './resources/recipes.js';
|
export { RECIPE_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from './resources/recipes.js';
|
||||||
export type { BatchesResource, BatchStatus } from './resources/batches.js';
|
export type { BatchesResource, BatchStatus } from './resources/batches.js';
|
||||||
export { BATCH_STATUS_LABELS } from './resources/batches.js';
|
export { BATCH_STATUS_LABELS } from './resources/batches.js';
|
||||||
export type { ProductionOrdersResource, Priority } from './resources/production-orders.js';
|
export type { ProductionOrdersResource, Priority, ProductionOrderStatus } from './resources/production-orders.js';
|
||||||
export { PRIORITY_LABELS } from './resources/production-orders.js';
|
export { PRIORITY_LABELS, PRODUCTION_ORDER_STATUS_LABELS } from './resources/production-orders.js';
|
||||||
export type { StocksResource, BatchType, StockBatchStatus, StockFilter, ReferenceType, ReservationPriority } from './resources/stocks.js';
|
export type { StocksResource, BatchType, StockBatchStatus, StockFilter, ReferenceType, ReservationPriority } from './resources/stocks.js';
|
||||||
|
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 { CountriesResource } from './resources/countries.js';
|
||||||
export { BATCH_TYPE_LABELS, STOCK_BATCH_STATUS_LABELS, REFERENCE_TYPE_LABELS, RESERVATION_PRIORITY_LABELS } from './resources/stocks.js';
|
export { BATCH_TYPE_LABELS, STOCK_BATCH_STATUS_LABELS, REFERENCE_TYPE_LABELS, RESERVATION_PRIORITY_LABELS } from './resources/stocks.js';
|
||||||
|
|
||||||
|
|
@ -159,6 +165,7 @@ import { createRecipesResource } from './resources/recipes.js';
|
||||||
import { createBatchesResource } from './resources/batches.js';
|
import { createBatchesResource } from './resources/batches.js';
|
||||||
import { createProductionOrdersResource } from './resources/production-orders.js';
|
import { createProductionOrdersResource } from './resources/production-orders.js';
|
||||||
import { createStocksResource } from './resources/stocks.js';
|
import { createStocksResource } from './resources/stocks.js';
|
||||||
|
import { createStockMovementsResource } from './resources/stock-movements.js';
|
||||||
import { createCountriesResource } from './resources/countries.js';
|
import { createCountriesResource } from './resources/countries.js';
|
||||||
import type { TokenProvider } from './token-provider.js';
|
import type { TokenProvider } from './token-provider.js';
|
||||||
import type { ApiConfig } from '@effigenix/config';
|
import type { ApiConfig } from '@effigenix/config';
|
||||||
|
|
@ -186,6 +193,7 @@ export function createEffigenixClient(
|
||||||
batches: createBatchesResource(axiosClient),
|
batches: createBatchesResource(axiosClient),
|
||||||
productionOrders: createProductionOrdersResource(axiosClient),
|
productionOrders: createProductionOrdersResource(axiosClient),
|
||||||
stocks: createStocksResource(axiosClient),
|
stocks: createStocksResource(axiosClient),
|
||||||
|
stockMovements: createStockMovementsResource(axiosClient),
|
||||||
countries: createCountriesResource(axiosClient),
|
countries: createCountriesResource(axiosClient),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/** Production Orders resource – Production BC. */
|
/** Production Orders resource – Production BC. */
|
||||||
|
|
||||||
import type { AxiosInstance } from 'axios';
|
import type { AxiosInstance } from 'axios';
|
||||||
import type { ProductionOrderDTO, CreateProductionOrderRequest } from '@effigenix/types';
|
import type { ProductionOrderDTO, CreateProductionOrderRequest, StartProductionOrderRequest } from '@effigenix/types';
|
||||||
|
|
||||||
export type Priority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
export type Priority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
||||||
|
|
||||||
|
|
@ -12,12 +12,32 @@ export const PRIORITY_LABELS: Record<Priority, string> = {
|
||||||
URGENT: 'Dringend',
|
URGENT: 'Dringend',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { ProductionOrderDTO, CreateProductionOrderRequest };
|
export type ProductionOrderStatus = 'CREATED' | 'RELEASED' | 'IN_PRODUCTION' | 'COMPLETED' | 'CANCELLED';
|
||||||
|
|
||||||
|
export const PRODUCTION_ORDER_STATUS_LABELS: Record<ProductionOrderStatus, string> = {
|
||||||
|
CREATED: 'Erstellt',
|
||||||
|
RELEASED: 'Freigegeben',
|
||||||
|
IN_PRODUCTION: 'In Produktion',
|
||||||
|
COMPLETED: 'Abgeschlossen',
|
||||||
|
CANCELLED: 'Storniert',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { ProductionOrderDTO, CreateProductionOrderRequest, StartProductionOrderRequest };
|
||||||
|
|
||||||
const BASE = '/api/production/production-orders';
|
const BASE = '/api/production/production-orders';
|
||||||
|
|
||||||
export function createProductionOrdersResource(client: AxiosInstance) {
|
export function createProductionOrdersResource(client: AxiosInstance) {
|
||||||
return {
|
return {
|
||||||
|
async list(): Promise<ProductionOrderDTO[]> {
|
||||||
|
const res = await client.get<ProductionOrderDTO[]>(BASE);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: string): Promise<ProductionOrderDTO> {
|
||||||
|
const res = await client.get<ProductionOrderDTO>(`${BASE}/${id}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
async create(request: CreateProductionOrderRequest): Promise<ProductionOrderDTO> {
|
async create(request: CreateProductionOrderRequest): Promise<ProductionOrderDTO> {
|
||||||
const res = await client.post<ProductionOrderDTO>(BASE, request);
|
const res = await client.post<ProductionOrderDTO>(BASE, request);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|
@ -27,6 +47,11 @@ export function createProductionOrdersResource(client: AxiosInstance) {
|
||||||
const res = await client.post<ProductionOrderDTO>(`${BASE}/${id}/release`);
|
const res = await client.post<ProductionOrderDTO>(`${BASE}/${id}/release`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async start(id: string, request: StartProductionOrderRequest): Promise<ProductionOrderDTO> {
|
||||||
|
const res = await client.post<ProductionOrderDTO>(`${BASE}/${id}/start`, request);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
/** Stock Movements resource – Inventory BC. */
|
||||||
|
|
||||||
|
import type { AxiosInstance } from 'axios';
|
||||||
|
import type { StockMovementDTO, RecordStockMovementRequest } from '@effigenix/types';
|
||||||
|
|
||||||
|
export type MovementType =
|
||||||
|
| 'GOODS_RECEIPT'
|
||||||
|
| 'PRODUCTION_OUTPUT'
|
||||||
|
| 'PRODUCTION_CONSUMPTION'
|
||||||
|
| 'SALE'
|
||||||
|
| 'RETURN'
|
||||||
|
| 'WASTE'
|
||||||
|
| 'ADJUSTMENT'
|
||||||
|
| 'INTER_BRANCH_TRANSFER';
|
||||||
|
|
||||||
|
export const MOVEMENT_TYPE_LABELS: Record<MovementType, string> = {
|
||||||
|
GOODS_RECEIPT: 'Wareneingang',
|
||||||
|
PRODUCTION_OUTPUT: 'Produktionsausstoß',
|
||||||
|
PRODUCTION_CONSUMPTION: 'Produktionsverbrauch',
|
||||||
|
SALE: 'Verkauf',
|
||||||
|
RETURN: 'Retoure',
|
||||||
|
WASTE: 'Ausschuss',
|
||||||
|
ADJUSTMENT: 'Korrektur',
|
||||||
|
INTER_BRANCH_TRANSFER: 'Filialumlagerung',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MovementDirection = 'IN' | 'OUT';
|
||||||
|
|
||||||
|
export const MOVEMENT_DIRECTION_LABELS: Record<MovementDirection, string> = {
|
||||||
|
IN: 'Eingang',
|
||||||
|
OUT: 'Ausgang',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { StockMovementDTO, RecordStockMovementRequest };
|
||||||
|
|
||||||
|
export interface StockMovementFilter {
|
||||||
|
stockId?: string;
|
||||||
|
articleId?: string;
|
||||||
|
movementType?: string;
|
||||||
|
batchReference?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = '/api/inventory/stock-movements';
|
||||||
|
|
||||||
|
export function createStockMovementsResource(client: AxiosInstance) {
|
||||||
|
return {
|
||||||
|
async list(filter?: StockMovementFilter): Promise<StockMovementDTO[]> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (filter?.stockId) params.stockId = filter.stockId;
|
||||||
|
if (filter?.articleId) params.articleId = filter.articleId;
|
||||||
|
if (filter?.movementType) params.movementType = filter.movementType;
|
||||||
|
if (filter?.batchReference) params.batchReference = filter.batchReference;
|
||||||
|
if (filter?.from) params.from = filter.from;
|
||||||
|
if (filter?.to) params.to = filter.to;
|
||||||
|
const res = await client.get<StockMovementDTO[]>(BASE, { params });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: string): Promise<StockMovementDTO> {
|
||||||
|
const res = await client.get<StockMovementDTO>(`${BASE}/${id}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async record(request: RecordStockMovementRequest): Promise<StockMovementDTO> {
|
||||||
|
const res = await client.post<StockMovementDTO>(BASE, request);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StockMovementsResource = ReturnType<typeof createStockMovementsResource>;
|
||||||
|
|
@ -452,6 +452,22 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/production/production-orders/{id}/start": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["startProductionOrder"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/production/production-orders/{id}/release": {
|
"/api/production/production-orders/{id}/release": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -660,6 +676,26 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/inventory/stock-movements": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List stock movements
|
||||||
|
* @description Filter priority (only one filter applied): stockId > articleId > batchReference > movementType > from/to
|
||||||
|
*/
|
||||||
|
get: operations["listMovements"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["recordMovement"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/customers": {
|
"/api/customers": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -996,6 +1032,38 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/inventory/stock-movements/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getMovement"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/countries": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["search"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/users/{id}/roles/{roleName}": {
|
"/api/users/{id}/roles/{roleName}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -1553,6 +1621,7 @@ export interface components {
|
||||||
id?: string;
|
id?: string;
|
||||||
recipeId?: string;
|
recipeId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
batchId?: string;
|
||||||
plannedQuantity?: string;
|
plannedQuantity?: string;
|
||||||
plannedQuantityUnit?: string;
|
plannedQuantityUnit?: string;
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
|
|
@ -1564,6 +1633,9 @@ export interface components {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
StartProductionOrderRequest: {
|
||||||
|
batchId: string;
|
||||||
|
};
|
||||||
PlanBatchRequest: {
|
PlanBatchRequest: {
|
||||||
recipeId: string;
|
recipeId: string;
|
||||||
plannedQuantity: string;
|
plannedQuantity: string;
|
||||||
|
|
@ -1668,6 +1740,36 @@ export interface components {
|
||||||
BlockStockBatchRequest: {
|
BlockStockBatchRequest: {
|
||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
|
RecordStockMovementRequest: {
|
||||||
|
stockId: string;
|
||||||
|
articleId: string;
|
||||||
|
stockBatchId: string;
|
||||||
|
batchId: string;
|
||||||
|
batchType: string;
|
||||||
|
movementType: string;
|
||||||
|
direction?: string;
|
||||||
|
quantityAmount: string;
|
||||||
|
quantityUnit: string;
|
||||||
|
reason?: string;
|
||||||
|
referenceDocumentId?: string;
|
||||||
|
};
|
||||||
|
StockMovementResponse: {
|
||||||
|
id: string;
|
||||||
|
stockId: string;
|
||||||
|
articleId: string;
|
||||||
|
stockBatchId: string;
|
||||||
|
batchId: string;
|
||||||
|
batchType: string;
|
||||||
|
movementType: string;
|
||||||
|
direction: string;
|
||||||
|
quantityAmount: number;
|
||||||
|
quantityUnit: string;
|
||||||
|
reason?: string | null;
|
||||||
|
referenceDocumentId?: string | null;
|
||||||
|
performedBy: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
performedAt: string;
|
||||||
|
};
|
||||||
CreateCustomerRequest: {
|
CreateCustomerRequest: {
|
||||||
name: string;
|
name: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
|
|
@ -1802,6 +1904,10 @@ export interface components {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
CountryResponse: {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
RemoveCertificateRequest: {
|
RemoveCertificateRequest: {
|
||||||
certificateType: string;
|
certificateType: string;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
|
|
@ -2891,6 +2997,32 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
startProductionOrder: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["StartProductionOrderRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["ProductionOrderResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
releaseProductionOrder: {
|
releaseProductionOrder: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -3278,6 +3410,57 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listMovements: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
stockId?: string;
|
||||||
|
articleId?: string;
|
||||||
|
movementType?: string;
|
||||||
|
batchReference?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["StockMovementResponse"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
recordMovement: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["RecordStockMovementRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["StockMovementResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
listCustomers: {
|
listCustomers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|
@ -3850,6 +4033,50 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getMovement: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["StockMovementResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
search: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
q?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["CountryResponse"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
removeRole: {
|
removeRole: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,7 @@ export type BlockStockBatchRequest = components['schemas']['BlockStockBatchReque
|
||||||
export type ReservationDTO = components['schemas']['ReservationResponse'];
|
export type ReservationDTO = components['schemas']['ReservationResponse'];
|
||||||
export type StockBatchAllocationDTO = components['schemas']['StockBatchAllocationResponse'];
|
export type StockBatchAllocationDTO = components['schemas']['StockBatchAllocationResponse'];
|
||||||
export type ReserveStockRequest = components['schemas']['ReserveStockRequest'];
|
export type ReserveStockRequest = components['schemas']['ReserveStockRequest'];
|
||||||
|
|
||||||
|
// Stock Movement types
|
||||||
|
export type StockMovementDTO = components['schemas']['StockMovementResponse'];
|
||||||
|
export type RecordStockMovementRequest = components['schemas']['RecordStockMovementRequest'];
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,4 @@ export type CancelBatchRequest = components['schemas']['CancelBatchRequest'];
|
||||||
// Production Order types
|
// Production Order types
|
||||||
export type ProductionOrderDTO = components['schemas']['ProductionOrderResponse'];
|
export type ProductionOrderDTO = components['schemas']['ProductionOrderResponse'];
|
||||||
export type CreateProductionOrderRequest = components['schemas']['CreateProductionOrderRequest'];
|
export type CreateProductionOrderRequest = components['schemas']['CreateProductionOrderRequest'];
|
||||||
|
export type StartProductionOrderRequest = components['schemas']['StartProductionOrderRequest'];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue