1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:29:35 +01:00

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
This commit is contained in:
Sebastian Frick 2026-02-20 01:07:32 +01:00
parent b46495e1aa
commit 6c1e6c24bc
48 changed files with 999 additions and 237 deletions

View file

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { RecipeDTO, RecipeType, ProductionStepDTO, IngredientDTO } from '@effigenix/api-client';
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';
@ -9,8 +9,8 @@ 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' | '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';
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';
@ -31,6 +31,13 @@ export function RecipeDetailScreen() {
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';
@ -39,6 +46,9 @@ export function RecipeDetailScreen() {
...(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]' },
@ -55,16 +65,25 @@ export function RecipeDetailScreen() {
? [...recipe.productionSteps].sort((a, b) => a.stepNumber - b.stepNumber)
: [];
const sortedIngredients = recipe?.ingredients
? [...recipe.ingredients].sort((a, b) => a.position - b.position)
: [];
const sortedIngredients = sortedIngredientsOf(recipe);
const loadRecipe = useCallback(() => {
setLoading(true);
setError(null);
client.recipes.getById(recipeId)
.then((r) => { setRecipe(r); setLoading(false); })
.catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
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]);
@ -98,6 +117,62 @@ export function RecipeDetailScreen() {
}
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 () => {
@ -117,6 +192,12 @@ export function RecipeDetailScreen() {
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;
@ -140,6 +221,51 @@ export function RecipeDetailScreen() {
}
};
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');
@ -246,15 +372,20 @@ export function RecipeDetailScreen() {
<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>
{recipe.ingredients.map((ing) => (
{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">(Artikel: {ing.articleId})</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>
))}
@ -276,6 +407,25 @@ export function RecipeDetailScreen() {
)}
</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>
@ -296,7 +446,7 @@ export function RecipeDetailScreen() {
{sortedIngredients.map((ing, index) => (
<Box key={ing.id}>
<Text color={index === selectedIngredientIndex ? 'cyan' : 'white'}>
{index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} (Artikel: {ing.articleId})
{index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} {articleMap[ing.articleId] ?? ing.articleId}
</Text>
</Box>
))}
@ -306,7 +456,7 @@ export function RecipeDetailScreen() {
{mode === 'confirm-remove-ingredient' && ingredientToRemove && (
<ConfirmDialog
message={`Zutat an Position ${ingredientToRemove.position} (Artikel: ${ingredientToRemove.articleId}) entfernen?`}
message={`Zutat an Position ${ingredientToRemove.position} (${articleMap[ingredientToRemove.articleId] ?? ingredientToRemove.articleId}) entfernen?`}
onConfirm={() => void handleRemoveIngredient()}
onCancel={() => { setMode('menu'); setIngredientToRemove(null); }}
/>
@ -356,3 +506,8 @@ export function RecipeDetailScreen() {
</Box>
);
}
function sortedIngredientsOf(recipe: RecipeDTO | null): IngredientDTO[] {
if (!recipe?.ingredients) return [];
return [...recipe.ingredients].sort((a, b) => a.position - b.position);
}