mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +01:00
- BatchNumber in allen ProductionOrder-Endpoints via BatchRepository auflösen - BatchCreationFailed Error-Variante statt generischem ValidationFailure - bestBeforeDate-Berechnung als Recipe.calculateBestBeforeDate() in die Domain verschoben
234 lines
9.1 KiB
TypeScript
234 lines
9.1 KiB
TypeScript
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>
|
||
);
|
||
}
|