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