diff --git a/frontend/apps/cli/src/App.tsx b/frontend/apps/cli/src/App.tsx
index cfe9cd7..f0d00ae 100644
--- a/frontend/apps/cli/src/App.tsx
+++ b/frontend/apps/cli/src/App.tsx
@@ -41,6 +41,7 @@ import { ProductionMenu } from './components/production/ProductionMenu.js';
import { RecipeListScreen } from './components/production/RecipeListScreen.js';
import { RecipeCreateScreen } from './components/production/RecipeCreateScreen.js';
import { RecipeDetailScreen } from './components/production/RecipeDetailScreen.js';
+import { AddProductionStepScreen } from './components/production/AddProductionStepScreen.js';
function ScreenRouter() {
const { isAuthenticated, loading } = useAuth();
@@ -107,6 +108,7 @@ function ScreenRouter() {
{current === 'recipe-list' && }
{current === 'recipe-create' && }
{current === 'recipe-detail' && }
+ {current === 'recipe-add-production-step' && }
);
}
diff --git a/frontend/apps/cli/src/components/production/AddProductionStepScreen.tsx b/frontend/apps/cli/src/components/production/AddProductionStepScreen.tsx
new file mode 100644
index 0000000..fb2fa50
--- /dev/null
+++ b/frontend/apps/cli/src/components/production/AddProductionStepScreen.tsx
@@ -0,0 +1,125 @@
+import { useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { useNavigation } from '../../state/navigation-context.js';
+import { FormInput } from '../shared/FormInput.js';
+import { LoadingSpinner } from '../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../shared/ErrorDisplay.js';
+import { client } from '../../utils/api-client.js';
+
+type Field = 'stepNumber' | 'description' | 'durationMinutes' | 'temperatureCelsius';
+const FIELDS: Field[] = ['stepNumber', 'description', 'durationMinutes', 'temperatureCelsius'];
+
+const FIELD_LABELS: Record = {
+ stepNumber: 'Schrittnummer (optional)',
+ description: 'Beschreibung *',
+ durationMinutes: 'Dauer in Minuten (optional)',
+ temperatureCelsius: 'Temperatur in °C (optional)',
+};
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : 'Unbekannter Fehler';
+}
+
+export function AddProductionStepScreen() {
+ const { params, navigate, back } = useNavigation();
+ const recipeId = params['recipeId'] ?? '';
+
+ const [values, setValues] = useState>({
+ stepNumber: '', description: '', durationMinutes: '', temperatureCelsius: '',
+ });
+ const [activeField, setActiveField] = useState('stepNumber');
+ const [fieldErrors, setFieldErrors] = useState>>({});
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const setField = (field: Field) => (value: string) => {
+ setValues((v) => ({ ...v, [field]: value }));
+ };
+
+ useInput((_input, key) => {
+ if (loading) return;
+ if (key.tab || key.downArrow) {
+ setActiveField((f) => {
+ const idx = FIELDS.indexOf(f);
+ return FIELDS[(idx + 1) % FIELDS.length] ?? f;
+ });
+ }
+ if (key.upArrow) {
+ setActiveField((f) => {
+ const idx = FIELDS.indexOf(f);
+ return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
+ });
+ }
+ if (key.escape) back();
+ });
+
+ const handleSubmit = async () => {
+ const errors: Partial> = {};
+ if (!values.description.trim()) errors.description = 'Beschreibung ist erforderlich.';
+ if (values.stepNumber.trim() && (!Number.isInteger(Number(values.stepNumber)) || Number(values.stepNumber) < 1)) {
+ errors.stepNumber = 'Muss eine positive Ganzzahl sein.';
+ }
+ if (values.durationMinutes.trim() && (!Number.isInteger(Number(values.durationMinutes)) || Number(values.durationMinutes) < 1)) {
+ errors.durationMinutes = 'Muss eine positive Ganzzahl sein.';
+ }
+ if (values.temperatureCelsius.trim() && isNaN(Number(values.temperatureCelsius))) {
+ errors.temperatureCelsius = 'Muss eine Zahl sein.';
+ }
+ setFieldErrors(errors);
+ if (Object.keys(errors).length > 0) return;
+
+ setLoading(true);
+ setError(null);
+ try {
+ await client.recipes.addProductionStep(recipeId, {
+ description: values.description.trim(),
+ ...(values.stepNumber.trim() ? { stepNumber: Number(values.stepNumber) } : {}),
+ ...(values.durationMinutes.trim() ? { durationMinutes: Number(values.durationMinutes) } : {}),
+ ...(values.temperatureCelsius.trim() ? { temperatureCelsius: Number(values.temperatureCelsius) } : {}),
+ });
+ navigate('recipe-detail', { recipeId });
+ } catch (err: unknown) {
+ setError(errorMessage(err));
+ setLoading(false);
+ }
+ };
+
+ const handleFieldSubmit = (field: Field) => (_value: string) => {
+ const idx = FIELDS.indexOf(field);
+ if (idx < FIELDS.length - 1) {
+ setActiveField(FIELDS[idx + 1] ?? field);
+ } else {
+ void handleSubmit();
+ }
+ };
+
+ if (!recipeId) return ;
+ if (loading) return ;
+
+ return (
+
+ Produktionsschritt hinzufügen
+ {error && setError(null)} />}
+
+
+ {FIELDS.map((field) => (
+
+ ))}
+
+
+
+
+ Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/production/RecipeDetailScreen.tsx b/frontend/apps/cli/src/components/production/RecipeDetailScreen.tsx
index 250cee1..a88db83 100644
--- a/frontend/apps/cli/src/components/production/RecipeDetailScreen.tsx
+++ b/frontend/apps/cli/src/components/production/RecipeDetailScreen.tsx
@@ -1,23 +1,52 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
-import type { RecipeDTO, RecipeType } from '@effigenix/api-client';
+import type { RecipeDTO, RecipeType, ProductionStepDTO } 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-step' | 'remove-step' | 'back';
+type Mode = 'menu' | 'select-step-to-remove' | 'confirm-remove';
+
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function RecipeDetailScreen() {
- const { params, back } = useNavigation();
+ 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 isDraft = recipe?.status === 'DRAFT';
+
+ const menuItems: { id: MenuAction; label: string }[] = [
+ ...(isDraft ? [
+ { id: 'add-step' as const, label: '[Schritt hinzufügen]' },
+ { id: 'remove-step' as const, label: '[Schritt entfernen]' },
+ ] : []),
+ { id: 'back' as const, label: '[Zurück]' },
+ ];
+
+ useEffect(() => {
+ setSelectedAction(0);
+ }, [isDraft]);
+
+ const sortedSteps = recipe?.productionSteps
+ ? [...recipe.productionSteps].sort((a, b) => a.stepNumber - b.stepNumber)
+ : [];
const loadRecipe = useCallback(() => {
setLoading(true);
@@ -30,10 +59,65 @@ export function RecipeDetailScreen() {
useEffect(() => { if (recipeId) loadRecipe(); }, [loadRecipe, recipeId]);
useInput((_input, key) => {
- if (loading) return;
- if (key.backspace || key.escape) back();
+ 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); }
+ }
});
+ const handleAction = async () => {
+ if (!recipe) return;
+ const item = menuItems[selectedAction];
+ if (!item) return;
+
+ switch (item.id) {
+ 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 'back':
+ back();
+ break;
+ }
+ };
+
+ 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]);
+
if (loading) return ;
if (error && !recipe) return ;
if (!recipe) return Rezept nicht gefunden.;
@@ -45,6 +129,7 @@ export function RecipeDetailScreen() {
Rezept: {recipe.name}
{error && setError(null)} />}
+ {successMessage && setSuccessMessage(null)} />}
@@ -93,10 +178,60 @@ export function RecipeDetailScreen() {
))}
)}
+
+ {sortedSteps.length > 0 && (
+
+ Produktionsschritte:
+ {sortedSteps.map((step) => (
+
+ {step.stepNumber}.
+ {step.description}
+ {step.durationMinutes != null && ({step.durationMinutes} min)}
+ {step.temperatureCelsius != null && [{step.temperatureCelsius}°C]}
+
+ ))}
+
+ )}
+ {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 === 'confirm-remove' && stepToRemove && (
+ void handleRemoveStep()}
+ onCancel={() => { setMode('menu'); setStepToRemove(null); }}
+ />
+ )}
+
+ {mode === 'menu' && (
+
+ Aktionen:
+ {actionLoading && }
+ {!actionLoading && menuItems.map((item, index) => (
+
+
+ {index === selectedAction ? '▶ ' : ' '}{item.label}
+
+
+ ))}
+
+ )}
+
- Backspace Zurück
+ ↑↓ navigieren · Enter ausführen · Backspace Zurück
);
diff --git a/frontend/apps/cli/src/state/navigation-context.tsx b/frontend/apps/cli/src/state/navigation-context.tsx
index db69836..33697a2 100644
--- a/frontend/apps/cli/src/state/navigation-context.tsx
+++ b/frontend/apps/cli/src/state/navigation-context.tsx
@@ -37,7 +37,8 @@ export type Screen =
| 'production-menu'
| 'recipe-list'
| 'recipe-create'
- | 'recipe-detail';
+ | 'recipe-detail'
+ | 'recipe-add-production-step';
interface NavigationState {
current: Screen;
diff --git a/frontend/packages/api-client/src/index.ts b/frontend/packages/api-client/src/index.ts
index a479b85..060849a 100644
--- a/frontend/packages/api-client/src/index.ts
+++ b/frontend/packages/api-client/src/index.ts
@@ -82,8 +82,10 @@ export type {
UpdateStorageLocationRequest,
RecipeDTO,
IngredientDTO,
+ ProductionStepDTO,
CreateRecipeRequest,
AddRecipeIngredientRequest,
+ AddProductionStepRequest,
} from '@effigenix/types';
// Resource types (runtime, stay in resource files)
diff --git a/frontend/packages/api-client/src/resources/recipes.ts b/frontend/packages/api-client/src/resources/recipes.ts
index fb56726..6908cca 100644
--- a/frontend/packages/api-client/src/resources/recipes.ts
+++ b/frontend/packages/api-client/src/resources/recipes.ts
@@ -9,8 +9,10 @@ import type { AxiosInstance } from 'axios';
import type {
RecipeDTO,
IngredientDTO,
+ ProductionStepDTO,
CreateRecipeRequest,
AddRecipeIngredientRequest,
+ AddProductionStepRequest,
} from '@effigenix/types';
export type RecipeType = 'RAW_MATERIAL' | 'INTERMEDIATE' | 'FINISHED_PRODUCT';
@@ -25,8 +27,10 @@ export const RECIPE_TYPE_LABELS: Record = {
export type {
RecipeDTO,
IngredientDTO,
+ ProductionStepDTO,
CreateRecipeRequest,
AddRecipeIngredientRequest,
+ AddProductionStepRequest,
};
// ── Resource factory ─────────────────────────────────────────────────────────
@@ -60,6 +64,15 @@ export function createRecipesResource(client: AxiosInstance) {
const res = await client.get(`${BASE}/${recipeId}`);
return res.data;
},
+
+ async addProductionStep(id: string, request: AddProductionStepRequest): Promise {
+ const res = await client.post(`${BASE}/${id}/steps`, request);
+ return res.data;
+ },
+
+ async removeProductionStep(id: string, stepNumber: number): Promise {
+ await client.delete(`${BASE}/${id}/steps/${stepNumber}`);
+ },
};
}
diff --git a/frontend/packages/types/src/production.ts b/frontend/packages/types/src/production.ts
index 43ff811..c4812ec 100644
--- a/frontend/packages/types/src/production.ts
+++ b/frontend/packages/types/src/production.ts
@@ -8,7 +8,9 @@ import type { components } from './generated/api';
// Response DTOs
export type RecipeDTO = components['schemas']['RecipeResponse'];
export type IngredientDTO = components['schemas']['IngredientResponse'];
+export type ProductionStepDTO = components['schemas']['ProductionStepResponse'];
// Request types
export type CreateRecipeRequest = components['schemas']['CreateRecipeRequest'];
export type AddRecipeIngredientRequest = components['schemas']['AddRecipeIngredientRequest'];
+export type AddProductionStepRequest = components['schemas']['AddProductionStepRequest'];
diff --git a/makefile b/makefile
index 6e18f23..579c03d 100644
--- a/makefile
+++ b/makefile
@@ -1,4 +1,8 @@
+.PHONY: frontend-dev backend/run generate/openapi
+
+frontend/run:
+ cd frontend && pnpm dev
backend/run:
cd backend && mvn spring-boot:run