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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [mode, setMode] = useState('menu'); const [selectedAction, setSelectedAction] = useState(0); const [actionLoading, setActionLoading] = useState(false); const [successMessage, setSuccessMessage] = useState(null); const [selectedStepIndex, setSelectedStepIndex] = useState(0); const [stepToRemove, setStepToRemove] = useState(null); const [selectedIngredientIndex, setSelectedIngredientIndex] = useState(0); const [ingredientToRemove, setIngredientToRemove] = useState(null); const [articleMap, setArticleMap] = useState>({}); const [recipeMap, setRecipeMap] = useState>({}); // Reorder state const [reorderList, setReorderList] = useState([]); const [reorderCursor, setReorderCursor] = useState(0); const [reorderHeld, setReorderHeld] = useState(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 = {}; for (const a of articles) aMap[a.id] = `${a.name} (${a.articleNumber})`; setArticleMap(aMap); const rMap: Record = {}; 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 ; if (error && !recipe) return ; if (!recipe) return Rezept nicht gefunden.; const typeName = RECIPE_TYPE_LABELS[recipe.type as RecipeType] ?? recipe.type; return ( Rezept: {recipe.name} {error && setError(null)} />} {successMessage && setSuccessMessage(null)} />} Status: {recipe.status} Typ: {typeName} Version: {recipe.version} {recipe.description && ( Beschreibung: {recipe.description} )} Ausbeute: {recipe.yieldPercentage}% {recipe.shelfLifeDays !== null && ( Haltbarkeit: {recipe.shelfLifeDays} Tage )} Ausgabemenge: {recipe.outputQuantity} {recipe.outputUom} Artikel: {articleMap[recipe.articleId] ?? recipe.articleId} {recipe.ingredients.length > 0 && ( Zutaten: {sortedIngredients.map((ing) => ( {ing.position}. {ing.quantity} {ing.uom} – {articleMap[ing.articleId] ?? ing.articleId} {ing.subRecipeId && [Rezept: {recipeMap[ing.subRecipeId] ?? ing.subRecipeId}]} {ing.substitutable && [austauschbar]} ))} )} {sortedSteps.length > 0 && ( Produktionsschritte: {sortedSteps.map((step) => ( {step.stepNumber}. {step.description} {step.durationMinutes != null && ({step.durationMinutes} min)} {step.temperatureCelsius != null && [{step.temperatureCelsius}°C]} ))} )} {mode === 'reorder-ingredient' && ( Zutaten neu anordnen: {reorderList.map((ing, index) => { const isCursor = index === reorderCursor; const isHeld = index === reorderHeld; const prefix = isHeld ? '[*] ' : isCursor ? ' ▶ ' : ' '; return ( {prefix}{index + 1}. {ing.quantity} {ing.uom} – {articleMap[ing.articleId] ?? ing.articleId} ); })} ↑↓ bewegen · Space greifen/loslassen · Enter speichern · Escape abbrechen )} {mode === 'select-step-to-remove' && ( Schritt zum Entfernen auswählen: {sortedSteps.map((step, index) => ( {index === selectedStepIndex ? '▶ ' : ' '}Schritt {step.stepNumber}: {step.description} ))} ↑↓ auswählen · Enter bestätigen · Escape abbrechen )} {mode === 'select-ingredient-to-remove' && ( Zutat zum Entfernen auswählen: {sortedIngredients.map((ing, index) => ( {index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} – {articleMap[ing.articleId] ?? ing.articleId} ))} ↑↓ auswählen · Enter bestätigen · Escape abbrechen )} {mode === 'confirm-remove-ingredient' && ingredientToRemove && ( void handleRemoveIngredient()} onCancel={() => { setMode('menu'); setIngredientToRemove(null); }} /> )} {mode === 'confirm-remove' && stepToRemove && ( void handleRemoveStep()} onCancel={() => { setMode('menu'); setStepToRemove(null); }} /> )} {mode === 'confirm-activate' && ( void handleActivate()} onCancel={() => setMode('menu')} /> )} {mode === 'confirm-archive' && ( void handleArchive()} onCancel={() => setMode('menu')} /> )} {mode === 'menu' && ( Aktionen: {actionLoading && } {!actionLoading && menuItems.map((item, index) => ( {index === clampedAction ? '▶ ' : ' '}{item.label} ))} )} ↑↓ navigieren · Enter ausführen · Backspace Zurück ); } function sortedIngredientsOf(recipe: RecipeDTO | null): IngredientDTO[] { if (!recipe?.ingredients) return []; return [...recipe.ingredients].sort((a, b) => a.position - b.position); }