1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 08:29:36 +01:00
effigenix/frontend/apps/cli/src/components/production/ProductionOrderDetailScreen.tsx
Sebastian Frick 600d0f9f06 feat(production): Batch bei Produktionsstart automatisch erstellen (#73)
- BatchNumber in allen ProductionOrder-Endpoints via BatchRepository auflösen
- BatchCreationFailed Error-Variante statt generischem ValidationFailure
- bestBeforeDate-Berechnung als Recipe.calculateBestBeforeDate() in die Domain verschoben
2026-02-26 12:32:04 +01:00

234 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useCallback } 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 { useRecipeNameLookup } from '../../hooks/useRecipeNameLookup.js';
import { client } from '../../utils/api-client.js';
import type { BatchDTO } from '@effigenix/api-client';
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> = {
PLANNED: 'gray',
RELEASED: 'yellow',
IN_PROGRESS: 'blue',
COMPLETED: 'green',
CANCELLED: 'red',
};
type Mode = 'view' | 'menu' | 'reschedule-input';
export function ProductionOrderDetailScreen() {
const { params, back } = useNavigation();
const {
productionOrder, loading, error,
fetchProductionOrder, releaseProductionOrder, rescheduleProductionOrder, startProductionOrder, clearError,
} = useProductionOrders();
const { recipeName } = useRecipeNameLookup();
const [mode, setMode] = useState<Mode>('view');
const [menuIndex, setMenuIndex] = useState(0);
const [newDate, setNewDate] = useState('');
const [success, setSuccess] = useState<string | null>(null);
const [batch, setBatch] = useState<BatchDTO | null>(null);
const orderId = params.orderId ?? '';
useEffect(() => {
if (orderId) void fetchProductionOrder(orderId);
}, [fetchProductionOrder, orderId]);
const loadBatch = useCallback(async (id: string) => {
try {
const b = await client.batches.getById(id);
setBatch(b);
} catch (err) {
setBatch(null);
}
}, []);
useEffect(() => {
if (productionOrder?.batchId) void loadBatch(productionOrder.batchId);
}, [loadBatch, productionOrder?.batchId]);
const getMenuItems = () => {
const items: { label: string; action: string }[] = [];
const status = productionOrder?.status;
if (status === 'PLANNED') {
items.push({ label: 'Freigeben', action: 'release' });
items.push({ label: 'Umterminieren', action: 'reschedule' });
}
if (status === 'RELEASED') {
items.push({ label: 'Produktion starten', action: 'start' });
items.push({ label: 'Umterminieren', action: 'reschedule' });
}
return items;
};
const menuItems = getMenuItems();
const handleRelease = async () => {
const result = await releaseProductionOrder(orderId);
if (result) {
setSuccess('Produktionsauftrag freigegeben.');
setMode('view');
}
};
const handleStart = async () => {
const result = await startProductionOrder(orderId);
if (result) {
const bn = result.batchNumber ? ` Charge: ${result.batchNumber}` : '';
setSuccess(`Produktion gestartet.${bn}`);
setMode('view');
}
};
const handleReschedule = async () => {
if (!newDate.trim()) return;
const result = await rescheduleProductionOrder(orderId, newDate.trim());
if (result) {
setSuccess(`Umterminiert auf ${newDate.trim()}.`);
setMode('view');
setNewDate('');
}
};
useInput((_input, key) => {
if (loading) return;
if (mode === 'reschedule-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 === 'reschedule') {
setMode('reschedule-input');
setNewDate('');
}
if (action === 'start') void handleStart();
}
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: </Text><Text>{recipeName(productionOrder.recipeId ?? '') ?? productionOrder.recipeId}</Text><Text color="gray" dimColor> ({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-Nr: </Text><Text>{batch?.batchNumber ?? 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>
{batch && productionOrder.batchId && (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Text color="cyan" bold>Produktionsergebnis</Text>
<Box><Text color="gray">Soll-Menge: </Text><Text>{batch.plannedQuantity} {batch.plannedQuantityUnit}</Text></Box>
{batch.actualQuantity && (
<Box><Text color="gray">Ist-Menge: </Text><Text color="green">{batch.actualQuantity} {batch.actualQuantityUnit}</Text></Box>
)}
{batch.waste && (
<Box><Text color="gray">Ausschuss: </Text><Text color="red">{batch.waste} {batch.wasteUnit}</Text></Box>
)}
{batch.remarks && (
<Box><Text color="gray">Bemerkungen: </Text><Text>{batch.remarks}</Text></Box>
)}
{batch.completedAt && (
<Box><Text color="gray">Abgeschl. am:</Text><Text> {new Date(batch.completedAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}</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 === 'reschedule-input' && (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>Neues Datum (YYYY-MM-DD):</Text>
<Box>
<Text color="gray"> </Text>
<TextInput
value={newDate}
onChange={setNewDate}
onSubmit={() => void handleReschedule()}
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>
);
}