1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 06:39:34 +01:00
effigenix/frontend/apps/cli/src/components/production/RecipeDetailScreen.tsx
Sebastian Frick 6c1e6c24bc feat(production): articleId für Rezepte, TUI-Verbesserungen mit UoM-Carousel, ArticlePicker und Zutaten-Reorder
Backend:
- articleId als Pflichtfeld im Recipe-Aggregate (Domain, Application, Infrastructure)
- Liquibase-Migration 015 mit defaultValue für bestehende Daten
- Alle Tests angepasst (Unit, Integration)

Frontend:
- UoM-Carousel-Selektor in RecipeCreateScreen, AddBatchScreen, AddIngredientScreen
- ArticlePicker-Komponente mit Typeahead-Suche für Artikelauswahl
- Auto-Position bei Zutatenzugabe (kein manuelles Feld mehr)
- Automatische subRecipeId-Erkennung bei Artikelauswahl
- Zutaten-Reorder per Drag im RecipeDetailScreen (Remove + Re-Add)
- Artikelnamen statt UUIDs in der Rezept-Detailansicht
- Navigation-Context: replace()-Methode ergänzt
2026-02-20 01:15:34 +01:00

513 lines
19 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, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { RecipeDTO, RecipeType, RecipeSummaryDTO, ProductionStepDTO, IngredientDTO, ArticleDTO } from '@effigenix/api-client';
import { RECIPE_TYPE_LABELS } from '@effigenix/api-client';
import { useNavigation } from '../../state/navigation-context.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { SuccessDisplay } from '../shared/SuccessDisplay.js';
import { ConfirmDialog } from '../shared/ConfirmDialog.js';
import { client } from '../../utils/api-client.js';
type MenuAction = 'add-ingredient' | 'remove-ingredient' | 'reorder-ingredients' | 'add-step' | 'remove-step' | 'activate' | 'archive' | 'back';
type Mode = 'menu' | 'select-step-to-remove' | 'confirm-remove' | 'select-ingredient-to-remove' | 'confirm-remove-ingredient' | 'confirm-activate' | 'confirm-archive' | 'reorder-ingredient';
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function RecipeDetailScreen() {
const { params, navigate, back } = useNavigation();
const recipeId = params['recipeId'] ?? '';
const [recipe, setRecipe] = useState<RecipeDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>('menu');
const [selectedAction, setSelectedAction] = useState(0);
const [actionLoading, setActionLoading] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [selectedStepIndex, setSelectedStepIndex] = useState(0);
const [stepToRemove, setStepToRemove] = useState<ProductionStepDTO | null>(null);
const [selectedIngredientIndex, setSelectedIngredientIndex] = useState(0);
const [ingredientToRemove, setIngredientToRemove] = useState<IngredientDTO | null>(null);
const [articleMap, setArticleMap] = useState<Record<string, string>>({});
const [recipeMap, setRecipeMap] = useState<Record<string, string>>({});
// Reorder state
const [reorderList, setReorderList] = useState<IngredientDTO[]>([]);
const [reorderCursor, setReorderCursor] = useState(0);
const [reorderHeld, setReorderHeld] = useState<number | null>(null);
const isDraft = recipe?.status === 'DRAFT';
const isActive = recipe?.status === 'ACTIVE';
const menuItems: { id: MenuAction; label: string }[] = [
...(isDraft ? [
{ id: 'add-ingredient' as const, label: '[Zutat hinzufügen]' },
{ id: 'remove-ingredient' as const, label: '[Zutat entfernen]' },
...(sortedIngredientsOf(recipe).length >= 2
? [{ id: 'reorder-ingredients' as const, label: '[Zutaten neu anordnen]' }]
: []),
{ id: 'add-step' as const, label: '[Schritt hinzufügen]' },
{ id: 'remove-step' as const, label: '[Schritt entfernen]' },
{ id: 'activate' as const, label: '[Rezept aktivieren]' },
] : []),
...(isActive ? [
{ id: 'archive' as const, label: '[Rezept archivieren]' },
] : []),
{ id: 'back' as const, label: '[Zurück]' },
];
const clampedAction = Math.min(selectedAction, menuItems.length - 1);
const sortedSteps = recipe?.productionSteps
? [...recipe.productionSteps].sort((a, b) => a.stepNumber - b.stepNumber)
: [];
const sortedIngredients = sortedIngredientsOf(recipe);
const loadRecipe = useCallback(() => {
setLoading(true);
setError(null);
Promise.all([
client.recipes.getById(recipeId),
client.articles.list().catch(() => [] as ArticleDTO[]),
client.recipes.list().catch(() => [] as RecipeSummaryDTO[]),
]).then(([r, articles, recipes]) => {
setRecipe(r);
const aMap: Record<string, string> = {};
for (const a of articles) aMap[a.id] = `${a.name} (${a.articleNumber})`;
setArticleMap(aMap);
const rMap: Record<string, string> = {};
for (const rec of recipes) rMap[rec.id] = `${rec.name} v${rec.version}`;
setRecipeMap(rMap);
setLoading(false);
}).catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
}, [recipeId]);
useEffect(() => { if (recipeId) loadRecipe(); }, [loadRecipe, recipeId]);
useInput((_input, key) => {
if (loading || actionLoading) return;
if (mode === 'menu') {
if (key.upArrow) setSelectedAction((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedAction((i) => Math.min(menuItems.length - 1, i + 1));
if (key.return) void handleAction();
if (key.backspace || key.escape) back();
}
if (mode === 'select-step-to-remove') {
if (key.upArrow) setSelectedStepIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedStepIndex((i) => Math.min(sortedSteps.length - 1, i + 1));
if (key.return && sortedSteps.length > 0) {
setStepToRemove(sortedSteps[selectedStepIndex] ?? null);
setMode('confirm-remove');
}
if (key.escape) { setMode('menu'); setSelectedStepIndex(0); }
}
if (mode === 'select-ingredient-to-remove') {
if (key.upArrow) setSelectedIngredientIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIngredientIndex((i) => Math.min(sortedIngredients.length - 1, i + 1));
if (key.return && sortedIngredients.length > 0) {
setIngredientToRemove(sortedIngredients[selectedIngredientIndex] ?? null);
setMode('confirm-remove-ingredient');
}
if (key.escape) { setMode('menu'); setSelectedIngredientIndex(0); }
}
if (mode === 'reorder-ingredient') {
if (key.escape) {
setMode('menu');
setReorderHeld(null);
return;
}
if (key.return) {
void handleReorderSave();
return;
}
if (_input === ' ') {
if (reorderHeld === null) {
setReorderHeld(reorderCursor);
} else {
setReorderHeld(null);
}
return;
}
if (key.upArrow) {
if (reorderHeld !== null) {
if (reorderCursor > 0) {
setReorderList((list) => {
const newList = [...list];
const temp = newList[reorderCursor]!;
newList[reorderCursor] = newList[reorderCursor - 1]!;
newList[reorderCursor - 1] = temp;
return newList;
});
setReorderHeld(reorderCursor - 1);
setReorderCursor((c) => c - 1);
}
} else {
setReorderCursor((c) => Math.max(0, c - 1));
}
return;
}
if (key.downArrow) {
if (reorderHeld !== null) {
if (reorderCursor < reorderList.length - 1) {
setReorderList((list) => {
const newList = [...list];
const temp = newList[reorderCursor]!;
newList[reorderCursor] = newList[reorderCursor + 1]!;
newList[reorderCursor + 1] = temp;
return newList;
});
setReorderHeld(reorderCursor + 1);
setReorderCursor((c) => c + 1);
}
} else {
setReorderCursor((c) => Math.min(reorderList.length - 1, c + 1));
}
return;
}
}
});
const handleAction = async () => {
if (!recipe) return;
const item = menuItems[clampedAction];
if (!item) return;
switch (item.id) {
case 'add-ingredient':
navigate('recipe-add-ingredient', { recipeId });
break;
case 'remove-ingredient':
if (sortedIngredients.length === 0) {
setError('Keine Zutaten vorhanden.');
return;
}
setSelectedIngredientIndex(0);
setMode('select-ingredient-to-remove');
break;
case 'reorder-ingredients':
setReorderList([...sortedIngredients]);
setReorderCursor(0);
setReorderHeld(null);
setMode('reorder-ingredient');
break;
case 'add-step':
navigate('recipe-add-production-step', { recipeId });
break;
case 'remove-step':
if (sortedSteps.length === 0) {
setError('Keine Produktionsschritte vorhanden.');
return;
}
setSelectedStepIndex(0);
setMode('select-step-to-remove');
break;
case 'activate':
setMode('confirm-activate');
break;
case 'archive':
setMode('confirm-archive');
break;
case 'back':
back();
break;
}
};
const handleReorderSave = useCallback(async () => {
if (!recipe) return;
const hasChanged = reorderList.some((ing, idx) => ing.id !== sortedIngredients[idx]?.id);
if (!hasChanged) {
setMode('menu');
setReorderHeld(null);
return;
}
setActionLoading(true);
setMode('menu');
setReorderHeld(null);
try {
// Remove all ingredients
for (const ing of sortedIngredients) {
await client.recipes.removeIngredient(recipe.id, ing.id);
}
// Re-add in new order
for (let i = 0; i < reorderList.length; i++) {
const ing = reorderList[i]!;
await client.recipes.addIngredient(recipe.id, {
articleId: ing.articleId,
quantity: ing.quantity,
uom: ing.uom,
position: i + 1,
...(ing.subRecipeId ? { subRecipeId: ing.subRecipeId } : {}),
substitutable: ing.substitutable,
});
}
const updated = await client.recipes.getById(recipe.id);
setRecipe(updated);
setSuccessMessage('Zutaten-Reihenfolge aktualisiert.');
} catch (err: unknown) {
setError(errorMessage(err));
// Reload to get consistent state
try {
const updated = await client.recipes.getById(recipe.id);
setRecipe(updated);
} catch { /* ignore reload error */ }
}
setActionLoading(false);
}, [recipe, reorderList, sortedIngredients]);
const handleRemoveStep = useCallback(async () => {
if (!recipe || !stepToRemove) return;
setMode('menu');
setActionLoading(true);
try {
await client.recipes.removeProductionStep(recipe.id, stepToRemove.stepNumber);
const updated = await client.recipes.getById(recipe.id);
setRecipe(updated);
setSuccessMessage(`Schritt ${stepToRemove.stepNumber} entfernt.`);
} catch (err: unknown) {
setError(errorMessage(err));
}
setActionLoading(false);
setStepToRemove(null);
}, [recipe, stepToRemove]);
const handleRemoveIngredient = useCallback(async () => {
if (!recipe || !ingredientToRemove) return;
setMode('menu');
setActionLoading(true);
try {
await client.recipes.removeIngredient(recipe.id, ingredientToRemove.id);
const updated = await client.recipes.getById(recipe.id);
setRecipe(updated);
setSuccessMessage(`Zutat (Position ${ingredientToRemove.position}) entfernt.`);
} catch (err: unknown) {
setError(errorMessage(err));
}
setActionLoading(false);
setIngredientToRemove(null);
}, [recipe, ingredientToRemove]);
const handleActivate = useCallback(async () => {
if (!recipe) return;
setMode('menu');
setActionLoading(true);
try {
const updated = await client.recipes.activateRecipe(recipe.id);
setRecipe(updated);
setSuccessMessage('Rezept wurde aktiviert.');
} catch (err: unknown) {
setError(errorMessage(err));
}
setActionLoading(false);
}, [recipe]);
const handleArchive = useCallback(async () => {
if (!recipe) return;
setMode('menu');
setActionLoading(true);
try {
const updated = await client.recipes.archiveRecipe(recipe.id);
setRecipe(updated);
setSuccessMessage('Rezept wurde archiviert.');
} catch (err: unknown) {
setError(errorMessage(err));
}
setActionLoading(false);
}, [recipe]);
if (loading) return <LoadingSpinner label="Lade Rezept..." />;
if (error && !recipe) return <ErrorDisplay message={error} onDismiss={back} />;
if (!recipe) return <Text color="red">Rezept nicht gefunden.</Text>;
const typeName = RECIPE_TYPE_LABELS[recipe.type as RecipeType] ?? recipe.type;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Rezept: {recipe.name}</Text>
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
<Box gap={2}>
<Text color="gray">Status:</Text>
<Text bold>{recipe.status}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Typ:</Text>
<Text>{typeName}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Version:</Text>
<Text>{recipe.version}</Text>
</Box>
{recipe.description && (
<Box gap={2}>
<Text color="gray">Beschreibung:</Text>
<Text>{recipe.description}</Text>
</Box>
)}
<Box gap={2}>
<Text color="gray">Ausbeute:</Text>
<Text>{recipe.yieldPercentage}%</Text>
</Box>
{recipe.shelfLifeDays !== null && (
<Box gap={2}>
<Text color="gray">Haltbarkeit:</Text>
<Text>{recipe.shelfLifeDays} Tage</Text>
</Box>
)}
<Box gap={2}>
<Text color="gray">Ausgabemenge:</Text>
<Text>{recipe.outputQuantity} {recipe.outputUom}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Artikel:</Text>
<Text>{articleMap[recipe.articleId] ?? recipe.articleId}</Text>
</Box>
{recipe.ingredients.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Zutaten:</Text>
{sortedIngredients.map((ing) => (
<Box key={ing.id} paddingLeft={2} gap={1}>
<Text color="yellow">{ing.position}.</Text>
<Text>{ing.quantity} {ing.uom}</Text>
<Text color="gray"> {articleMap[ing.articleId] ?? ing.articleId}</Text>
{ing.subRecipeId && <Text color="blue">[Rezept: {recipeMap[ing.subRecipeId] ?? ing.subRecipeId}]</Text>}
{ing.substitutable && <Text color="green">[austauschbar]</Text>}
</Box>
))}
</Box>
)}
{sortedSteps.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Produktionsschritte:</Text>
{sortedSteps.map((step) => (
<Box key={step.id} paddingLeft={2} gap={1}>
<Text color="yellow">{step.stepNumber}.</Text>
<Text>{step.description}</Text>
{step.durationMinutes != null && <Text color="gray">({step.durationMinutes} min)</Text>}
{step.temperatureCelsius != null && <Text color="gray">[{step.temperatureCelsius}°C]</Text>}
</Box>
))}
</Box>
)}
</Box>
{mode === 'reorder-ingredient' && (
<Box flexDirection="column">
<Text color="yellow" bold>Zutaten neu anordnen:</Text>
{reorderList.map((ing, index) => {
const isCursor = index === reorderCursor;
const isHeld = index === reorderHeld;
const prefix = isHeld ? '[*] ' : isCursor ? ' ▶ ' : ' ';
return (
<Box key={`${ing.id}-${index}`}>
<Text color={isCursor ? 'cyan' : isHeld ? 'yellow' : 'white'}>
{prefix}{index + 1}. {ing.quantity} {ing.uom} {articleMap[ing.articleId] ?? ing.articleId}
</Text>
</Box>
);
})}
<Text color="gray" dimColor> bewegen · Space greifen/loslassen · Enter speichern · Escape abbrechen</Text>
</Box>
)}
{mode === 'select-step-to-remove' && (
<Box flexDirection="column">
<Text color="yellow" bold>Schritt zum Entfernen auswählen:</Text>
{sortedSteps.map((step, index) => (
<Box key={step.id}>
<Text color={index === selectedStepIndex ? 'cyan' : 'white'}>
{index === selectedStepIndex ? '▶ ' : ' '}Schritt {step.stepNumber}: {step.description}
</Text>
</Box>
))}
<Text color="gray" dimColor> auswählen · Enter bestätigen · Escape abbrechen</Text>
</Box>
)}
{mode === 'select-ingredient-to-remove' && (
<Box flexDirection="column">
<Text color="yellow" bold>Zutat zum Entfernen auswählen:</Text>
{sortedIngredients.map((ing, index) => (
<Box key={ing.id}>
<Text color={index === selectedIngredientIndex ? 'cyan' : 'white'}>
{index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} {articleMap[ing.articleId] ?? ing.articleId}
</Text>
</Box>
))}
<Text color="gray" dimColor> auswählen · Enter bestätigen · Escape abbrechen</Text>
</Box>
)}
{mode === 'confirm-remove-ingredient' && ingredientToRemove && (
<ConfirmDialog
message={`Zutat an Position ${ingredientToRemove.position} (${articleMap[ingredientToRemove.articleId] ?? ingredientToRemove.articleId}) entfernen?`}
onConfirm={() => void handleRemoveIngredient()}
onCancel={() => { setMode('menu'); setIngredientToRemove(null); }}
/>
)}
{mode === 'confirm-remove' && stepToRemove && (
<ConfirmDialog
message={`Schritt ${stepToRemove.stepNumber} "${stepToRemove.description}" entfernen?`}
onConfirm={() => void handleRemoveStep()}
onCancel={() => { setMode('menu'); setStepToRemove(null); }}
/>
)}
{mode === 'confirm-activate' && (
<ConfirmDialog
message="Rezept wirklich aktivieren? Der Status wechselt zu ACTIVE."
onConfirm={() => void handleActivate()}
onCancel={() => setMode('menu')}
/>
)}
{mode === 'confirm-archive' && (
<ConfirmDialog
message="Rezept wirklich archivieren? Der Status wechselt zu ARCHIVED."
onConfirm={() => void handleArchive()}
onCancel={() => setMode('menu')}
/>
)}
{mode === 'menu' && (
<Box flexDirection="column">
<Text color="gray" bold>Aktionen:</Text>
{actionLoading && <LoadingSpinner label="Aktion wird ausgeführt..." />}
{!actionLoading && menuItems.map((item, index) => (
<Box key={item.id}>
<Text color={index === clampedAction ? 'cyan' : 'white'}>
{index === clampedAction ? '▶ ' : ' '}{item.label}
</Text>
</Box>
))}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor> navigieren · Enter ausführen · Backspace Zurück</Text>
</Box>
</Box>
);
}
function sortedIngredientsOf(recipe: RecipeDTO | null): IngredientDTO[] {
if (!recipe?.ingredients) return [];
return [...recipe.ingredients].sort((a, b) => a.position - b.position);
}