1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 08:29:36 +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

@ -26,7 +26,7 @@ public class CreateRecipe {
var draft = new RecipeDraft( var draft = new RecipeDraft(
cmd.name(), cmd.version(), cmd.type(), cmd.description(), cmd.name(), cmd.version(), cmd.type(), cmd.description(),
cmd.yieldPercentage(), cmd.shelfLifeDays(), cmd.yieldPercentage(), cmd.shelfLifeDays(),
cmd.outputQuantity(), cmd.outputUom() cmd.outputQuantity(), cmd.outputUom(), cmd.articleId()
); );
Recipe recipe; Recipe recipe;

View file

@ -10,5 +10,6 @@ public record CreateRecipeCommand(
int yieldPercentage, int yieldPercentage,
Integer shelfLifeDays, Integer shelfLifeDays,
String outputQuantity, String outputQuantity,
String outputUom String outputUom,
String articleId
) {} ) {}

View file

@ -31,6 +31,7 @@ import static de.effigenix.shared.common.Result.*;
* 11. Recipe can only be activated when it has at least one ingredient * 11. Recipe can only be activated when it has at least one ingredient
* 12. Recipe can only be activated from DRAFT status * 12. Recipe can only be activated from DRAFT status
* 13. Recipe can only be archived from ACTIVE status * 13. Recipe can only be archived from ACTIVE status
* 14. ArticleId must not be blank (references the output article in Master Data)
*/ */
public class Recipe { public class Recipe {
@ -42,6 +43,7 @@ public class Recipe {
private YieldPercentage yieldPercentage; private YieldPercentage yieldPercentage;
private Integer shelfLifeDays; private Integer shelfLifeDays;
private Quantity outputQuantity; private Quantity outputQuantity;
private String articleId;
private RecipeStatus status; private RecipeStatus status;
private final List<Ingredient> ingredients; private final List<Ingredient> ingredients;
private final List<ProductionStep> productionSteps; private final List<ProductionStep> productionSteps;
@ -57,6 +59,7 @@ public class Recipe {
YieldPercentage yieldPercentage, YieldPercentage yieldPercentage,
Integer shelfLifeDays, Integer shelfLifeDays,
Quantity outputQuantity, Quantity outputQuantity,
String articleId,
RecipeStatus status, RecipeStatus status,
List<Ingredient> ingredients, List<Ingredient> ingredients,
List<ProductionStep> productionSteps, List<ProductionStep> productionSteps,
@ -71,6 +74,7 @@ public class Recipe {
this.yieldPercentage = yieldPercentage; this.yieldPercentage = yieldPercentage;
this.shelfLifeDays = shelfLifeDays; this.shelfLifeDays = shelfLifeDays;
this.outputQuantity = outputQuantity; this.outputQuantity = outputQuantity;
this.articleId = articleId;
this.status = status; this.status = status;
this.ingredients = new ArrayList<>(ingredients); this.ingredients = new ArrayList<>(ingredients);
this.productionSteps = new ArrayList<>(productionSteps); this.productionSteps = new ArrayList<>(productionSteps);
@ -111,7 +115,12 @@ public class Recipe {
} }
} }
// 5. Output Quantity (required, positive) // 5. ArticleId (required)
if (draft.articleId() == null || draft.articleId().isBlank()) {
return Result.failure(new RecipeError.ValidationFailure("ArticleId must not be blank"));
}
// 6. Output Quantity (required, positive)
Quantity outputQuantity; Quantity outputQuantity;
try { try {
BigDecimal amount = new BigDecimal(draft.outputQuantity()); BigDecimal amount = new BigDecimal(draft.outputQuantity());
@ -128,7 +137,7 @@ public class Recipe {
return Result.success(new Recipe( return Result.success(new Recipe(
RecipeId.generate(), name, draft.version(), draft.type(), RecipeId.generate(), name, draft.version(), draft.type(),
draft.description(), yieldPercentage, shelfLifeDays, outputQuantity, draft.description(), yieldPercentage, shelfLifeDays, outputQuantity,
RecipeStatus.DRAFT, List.of(), List.of(), now, now draft.articleId(), RecipeStatus.DRAFT, List.of(), List.of(), now, now
)); ));
} }
@ -144,6 +153,7 @@ public class Recipe {
YieldPercentage yieldPercentage, YieldPercentage yieldPercentage,
Integer shelfLifeDays, Integer shelfLifeDays,
Quantity outputQuantity, Quantity outputQuantity,
String articleId,
RecipeStatus status, RecipeStatus status,
List<Ingredient> ingredients, List<Ingredient> ingredients,
List<ProductionStep> productionSteps, List<ProductionStep> productionSteps,
@ -151,7 +161,7 @@ public class Recipe {
OffsetDateTime updatedAt OffsetDateTime updatedAt
) { ) {
return new Recipe(id, name, version, type, description, return new Recipe(id, name, version, type, description,
yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, productionSteps, createdAt, updatedAt); yieldPercentage, shelfLifeDays, outputQuantity, articleId, status, ingredients, productionSteps, createdAt, updatedAt);
} }
// ==================== Ingredient Management ==================== // ==================== Ingredient Management ====================
@ -253,6 +263,7 @@ public class Recipe {
public YieldPercentage yieldPercentage() { return yieldPercentage; } public YieldPercentage yieldPercentage() { return yieldPercentage; }
public Integer shelfLifeDays() { return shelfLifeDays; } public Integer shelfLifeDays() { return shelfLifeDays; }
public Quantity outputQuantity() { return outputQuantity; } public Quantity outputQuantity() { return outputQuantity; }
public String articleId() { return articleId; }
public RecipeStatus status() { return status; } public RecipeStatus status() { return status; }
public List<Ingredient> ingredients() { return Collections.unmodifiableList(ingredients); } public List<Ingredient> ingredients() { return Collections.unmodifiableList(ingredients); }
public List<ProductionStep> productionSteps() { return Collections.unmodifiableList(productionSteps); } public List<ProductionStep> productionSteps() { return Collections.unmodifiableList(productionSteps); }

View file

@ -12,6 +12,7 @@ package de.effigenix.domain.production;
* @param shelfLifeDays Shelf life in days (nullable; required for FINISHED_PRODUCT and INTERMEDIATE) * @param shelfLifeDays Shelf life in days (nullable; required for FINISHED_PRODUCT and INTERMEDIATE)
* @param outputQuantity Expected output quantity amount (required) * @param outputQuantity Expected output quantity amount (required)
* @param outputUom Expected output unit of measure (required) * @param outputUom Expected output unit of measure (required)
* @param articleId Article ID of the output product (optional; null if not linked)
*/ */
public record RecipeDraft( public record RecipeDraft(
String name, String name,
@ -21,5 +22,6 @@ public record RecipeDraft(
int yieldPercentage, int yieldPercentage,
Integer shelfLifeDays, Integer shelfLifeDays,
String outputQuantity, String outputQuantity,
String outputUom String outputUom,
String articleId
) {} ) {}

View file

@ -40,6 +40,9 @@ public class RecipeEntity {
@Column(name = "output_uom", nullable = false, length = 20) @Column(name = "output_uom", nullable = false, length = 20)
private String outputUom; private String outputUom;
@Column(name = "article_id", nullable = false, length = 36)
private String articleId;
@Column(name = "status", nullable = false, length = 20) @Column(name = "status", nullable = false, length = 20)
private String status; private String status;
@ -61,7 +64,7 @@ public class RecipeEntity {
public RecipeEntity(String id, String name, int version, String type, String description, public RecipeEntity(String id, String name, int version, String type, String description,
int yieldPercentage, Integer shelfLifeDays, BigDecimal outputQuantity, int yieldPercentage, Integer shelfLifeDays, BigDecimal outputQuantity,
String outputUom, String status, OffsetDateTime createdAt, OffsetDateTime updatedAt) { String outputUom, String articleId, String status, OffsetDateTime createdAt, OffsetDateTime updatedAt) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.version = version; this.version = version;
@ -71,6 +74,7 @@ public class RecipeEntity {
this.shelfLifeDays = shelfLifeDays; this.shelfLifeDays = shelfLifeDays;
this.outputQuantity = outputQuantity; this.outputQuantity = outputQuantity;
this.outputUom = outputUom; this.outputUom = outputUom;
this.articleId = articleId;
this.status = status; this.status = status;
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
@ -85,6 +89,7 @@ public class RecipeEntity {
public Integer getShelfLifeDays() { return shelfLifeDays; } public Integer getShelfLifeDays() { return shelfLifeDays; }
public BigDecimal getOutputQuantity() { return outputQuantity; } public BigDecimal getOutputQuantity() { return outputQuantity; }
public String getOutputUom() { return outputUom; } public String getOutputUom() { return outputUom; }
public String getArticleId() { return articleId; }
public String getStatus() { return status; } public String getStatus() { return status; }
public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; } public OffsetDateTime getUpdatedAt() { return updatedAt; }
@ -100,6 +105,7 @@ public class RecipeEntity {
public void setShelfLifeDays(Integer shelfLifeDays) { this.shelfLifeDays = shelfLifeDays; } public void setShelfLifeDays(Integer shelfLifeDays) { this.shelfLifeDays = shelfLifeDays; }
public void setOutputQuantity(BigDecimal outputQuantity) { this.outputQuantity = outputQuantity; } public void setOutputQuantity(BigDecimal outputQuantity) { this.outputQuantity = outputQuantity; }
public void setOutputUom(String outputUom) { this.outputUom = outputUom; } public void setOutputUom(String outputUom) { this.outputUom = outputUom; }
public void setArticleId(String articleId) { this.articleId = articleId; }
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }

View file

@ -25,6 +25,7 @@ public class RecipeMapper {
recipe.shelfLifeDays(), recipe.shelfLifeDays(),
recipe.outputQuantity().amount(), recipe.outputQuantity().amount(),
recipe.outputQuantity().uom().name(), recipe.outputQuantity().uom().name(),
recipe.articleId(),
recipe.status().name(), recipe.status().name(),
recipe.createdAt(), recipe.createdAt(),
recipe.updatedAt() recipe.updatedAt()
@ -64,6 +65,7 @@ public class RecipeMapper {
entity.getOutputQuantity(), entity.getOutputQuantity(),
UnitOfMeasure.valueOf(entity.getOutputUom()) UnitOfMeasure.valueOf(entity.getOutputUom())
), ),
entity.getArticleId(),
RecipeStatus.valueOf(entity.getStatus()), RecipeStatus.valueOf(entity.getStatus()),
ingredients, ingredients,
productionSteps, productionSteps,

View file

@ -136,7 +136,7 @@ public class RecipeController {
var cmd = new CreateRecipeCommand( var cmd = new CreateRecipeCommand(
request.name(), request.version(), request.type(), request.description(), request.name(), request.version(), request.type(), request.description(),
request.yieldPercentage(), request.shelfLifeDays(), request.yieldPercentage(), request.shelfLifeDays(),
request.outputQuantity(), request.outputUom() request.outputQuantity(), request.outputUom(), request.articleId()
); );
var result = createRecipe.execute(cmd, actorId); var result = createRecipe.execute(cmd, actorId);

View file

@ -12,5 +12,6 @@ public record CreateRecipeRequest(
int yieldPercentage, int yieldPercentage,
Integer shelfLifeDays, Integer shelfLifeDays,
@NotBlank String outputQuantity, @NotBlank String outputQuantity,
@NotBlank String outputUom @NotBlank String outputUom,
@NotBlank String articleId
) {} ) {}

View file

@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "productionSteps", "createdAt", "updatedAt"}) @Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "articleId", "status", "ingredients", "productionSteps", "createdAt", "updatedAt"})
public record RecipeResponse( public record RecipeResponse(
String id, String id,
String name, String name,
@ -17,6 +17,7 @@ public record RecipeResponse(
@Schema(nullable = true) Integer shelfLifeDays, @Schema(nullable = true) Integer shelfLifeDays,
String outputQuantity, String outputQuantity,
String outputUom, String outputUom,
String articleId,
String status, String status,
List<IngredientResponse> ingredients, List<IngredientResponse> ingredients,
List<ProductionStepResponse> productionSteps, List<ProductionStepResponse> productionSteps,
@ -34,6 +35,7 @@ public record RecipeResponse(
recipe.shelfLifeDays(), recipe.shelfLifeDays(),
recipe.outputQuantity().amount().toPlainString(), recipe.outputQuantity().amount().toPlainString(),
recipe.outputQuantity().uom().name(), recipe.outputQuantity().uom().name(),
recipe.articleId(),
recipe.status().name(), recipe.status().name(),
recipe.ingredients().stream().map(IngredientResponse::from).toList(), recipe.ingredients().stream().map(IngredientResponse::from).toList(),
recipe.productionSteps().stream().map(ProductionStepResponse::from).toList(), recipe.productionSteps().stream().map(ProductionStepResponse::from).toList(),

View file

@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredientCount", "stepCount", "createdAt", "updatedAt"}) @Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "articleId", "status", "ingredientCount", "stepCount", "createdAt", "updatedAt"})
public record RecipeSummaryResponse( public record RecipeSummaryResponse(
String id, String id,
String name, String name,
@ -16,6 +16,7 @@ public record RecipeSummaryResponse(
@Schema(nullable = true) Integer shelfLifeDays, @Schema(nullable = true) Integer shelfLifeDays,
String outputQuantity, String outputQuantity,
String outputUom, String outputUom,
String articleId,
String status, String status,
int ingredientCount, int ingredientCount,
int stepCount, int stepCount,
@ -33,6 +34,7 @@ public record RecipeSummaryResponse(
recipe.shelfLifeDays(), recipe.shelfLifeDays(),
recipe.outputQuantity().amount().toPlainString(), recipe.outputQuantity().amount().toPlainString(),
recipe.outputQuantity().uom().name(), recipe.outputQuantity().uom().name(),
recipe.articleId(),
recipe.status().name(), recipe.status().name(),
recipe.ingredients().size(), recipe.ingredients().size(),
recipe.productionSteps().size(), recipe.productionSteps().size(),

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="018-add-article-id-to-recipes" author="effigenix">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="recipes" columnName="article_id"/>
</not>
</preConditions>
<addColumn tableName="recipes">
<column name="article_id" type="VARCHAR(36)" defaultValue="UNKNOWN">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>

View file

@ -22,5 +22,6 @@
<include file="db/changelog/changes/015-create-batches-table.xml"/> <include file="db/changelog/changes/015-create-batches-table.xml"/>
<include file="db/changelog/changes/016-create-batch-number-sequences-table.xml"/> <include file="db/changelog/changes/016-create-batch-number-sequences-table.xml"/>
<include file="db/changelog/changes/017-timestamps-to-timestamptz.xml"/> <include file="db/changelog/changes/017-timestamps-to-timestamptz.xml"/>
<include file="db/changelog/changes/018-add-article-id-to-recipes.xml"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -43,7 +43,7 @@ class ActivateRecipeTest {
private Recipe draftRecipeWithIngredient() { private Recipe draftRecipeWithIngredient() {
var recipe = Recipe.create(new RecipeDraft( var recipe = Recipe.create(new RecipeDraft(
"Bratwurst", 1, RecipeType.FINISHED_PRODUCT, "Bratwurst", 1, RecipeType.FINISHED_PRODUCT,
null, 85, 14, "100", "KILOGRAM" null, 85, 14, "100", "KILOGRAM", "article-123"
)).unsafeGetValue(); )).unsafeGetValue();
recipe.addIngredient(new IngredientDraft(1, "article-123", "5.5", "KILOGRAM", null, false)); recipe.addIngredient(new IngredientDraft(1, "article-123", "5.5", "KILOGRAM", null, false));
return recipe; return recipe;
@ -52,7 +52,7 @@ class ActivateRecipeTest {
private Recipe draftRecipeWithoutIngredient() { private Recipe draftRecipeWithoutIngredient() {
return Recipe.create(new RecipeDraft( return Recipe.create(new RecipeDraft(
"Bratwurst", 1, RecipeType.FINISHED_PRODUCT, "Bratwurst", 1, RecipeType.FINISHED_PRODUCT,
null, 85, 14, "100", "KILOGRAM" null, 85, 14, "100", "KILOGRAM", "article-123"
)).unsafeGetValue(); )).unsafeGetValue();
} }
@ -61,7 +61,7 @@ class ActivateRecipeTest {
RecipeId.of("recipe-1"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.of("recipe-1"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
); );
} }

View file

@ -39,7 +39,7 @@ class AddRecipeIngredientTest {
private Recipe draftRecipe() { private Recipe draftRecipe() {
return Recipe.create(new RecipeDraft( return Recipe.create(new RecipeDraft(
"Bratwurst", 1, RecipeType.FINISHED_PRODUCT, "Bratwurst", 1, RecipeType.FINISHED_PRODUCT,
null, 85, 14, "100", "KILOGRAM" null, 85, 14, "100", "KILOGRAM", "article-123"
)).unsafeGetValue(); )).unsafeGetValue();
} }

View file

@ -45,7 +45,7 @@ class ArchiveRecipeTest {
RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
); );
} }
@ -53,7 +53,7 @@ class ArchiveRecipeTest {
private Recipe draftRecipe() { private Recipe draftRecipe() {
return Recipe.create(new RecipeDraft( return Recipe.create(new RecipeDraft(
"Bratwurst", 1, RecipeType.FINISHED_PRODUCT, "Bratwurst", 1, RecipeType.FINISHED_PRODUCT,
null, 85, 14, "100", "KILOGRAM" null, 85, 14, "100", "KILOGRAM", "article-123"
)).unsafeGetValue(); )).unsafeGetValue();
} }
@ -118,7 +118,7 @@ class ArchiveRecipeTest {
RecipeId.of("recipe-2"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.of("recipe-2"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ARCHIVED, List.of(), List.of(), "article-123", RecipeStatus.ARCHIVED, List.of(), List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
); );
when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true);

View file

@ -43,7 +43,7 @@ class GetRecipeTest {
RecipeId.of(id), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.of(id), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
"Beschreibung", new YieldPercentage(85), 14, "Beschreibung", new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
); );
} }

View file

@ -42,7 +42,7 @@ class ListRecipesTest {
RecipeId.of(id), new RecipeName("Rezept-" + id), 1, RecipeType.FINISHED_PRODUCT, RecipeId.of(id), new RecipeName("Rezept-" + id), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
status, List.of(), List.of(), "article-123", status, List.of(), List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
); );
} }

View file

@ -57,7 +57,7 @@ class PlanBatchTest {
RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
); );
} }
@ -67,7 +67,7 @@ class PlanBatchTest {
RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.DRAFT, List.of(), List.of(), "article-123", RecipeStatus.DRAFT, List.of(), List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)
); );
} }

View file

@ -48,7 +48,7 @@ class RecipeCycleCheckerTest {
RecipeId.of(id), new RecipeName("Recipe-" + id), 1, RecipeType.FINISHED_PRODUCT, RecipeId.of(id), new RecipeName("Recipe-" + id), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(100), 14, null, new YieldPercentage(100), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.DRAFT, ingredients, List.of(), "article-123", RecipeStatus.DRAFT, ingredients, List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)); OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC));
} }
@ -145,7 +145,7 @@ class RecipeCycleCheckerTest {
RecipeId.of("B"), new RecipeName("Recipe-B"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.of("B"), new RecipeName("Recipe-B"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(100), 14, null, new YieldPercentage(100), 14,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.DRAFT, ingredients, List.of(), "article-123", RecipeStatus.DRAFT, ingredients, List.of(),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)); OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC));
when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeB))); when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeB)));

View file

@ -17,7 +17,7 @@ class RecipeTest {
return new RecipeDraft( return new RecipeDraft(
"Bratwurst Grob", 1, RecipeType.FINISHED_PRODUCT, "Bratwurst Grob", 1, RecipeType.FINISHED_PRODUCT,
"Grobe Bratwurst nach Hausrezept", 85, "Grobe Bratwurst nach Hausrezept", 85,
14, "100", "KILOGRAM" 14, "100", "KILOGRAM", "article-123"
); );
} }
@ -56,7 +56,7 @@ class RecipeTest {
@DisplayName("should fail when name is blank") @DisplayName("should fail when name is blank")
void should_Fail_When_NameIsBlank() { void should_Fail_When_NameIsBlank() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" "", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -69,7 +69,7 @@ class RecipeTest {
@DisplayName("should fail when name is null") @DisplayName("should fail when name is null")
void should_Fail_When_NameIsNull() { void should_Fail_When_NameIsNull() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
null, 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" null, 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -82,7 +82,7 @@ class RecipeTest {
@DisplayName("should fail when version is less than 1") @DisplayName("should fail when version is less than 1")
void should_Fail_When_VersionLessThanOne() { void should_Fail_When_VersionLessThanOne() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Test", 0, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" "Test", 0, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -95,7 +95,7 @@ class RecipeTest {
@DisplayName("should fail when yield percentage is 0") @DisplayName("should fail when yield percentage is 0")
void should_Fail_When_YieldPercentageIsZero() { void should_Fail_When_YieldPercentageIsZero() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 0, 14, "100", "KILOGRAM" "Test", 1, RecipeType.FINISHED_PRODUCT, null, 0, 14, "100", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -108,7 +108,7 @@ class RecipeTest {
@DisplayName("should fail when yield percentage is 201") @DisplayName("should fail when yield percentage is 201")
void should_Fail_When_YieldPercentageIs201() { void should_Fail_When_YieldPercentageIs201() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 201, 14, "100", "KILOGRAM" "Test", 1, RecipeType.FINISHED_PRODUCT, null, 201, 14, "100", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -121,7 +121,7 @@ class RecipeTest {
@DisplayName("should fail when shelf life is 0 for FINISHED_PRODUCT") @DisplayName("should fail when shelf life is 0 for FINISHED_PRODUCT")
void should_Fail_When_ShelfLifeZeroForFinishedProduct() { void should_Fail_When_ShelfLifeZeroForFinishedProduct() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 0, "100", "KILOGRAM" "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 0, "100", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -134,7 +134,7 @@ class RecipeTest {
@DisplayName("should fail when shelf life is null for FINISHED_PRODUCT") @DisplayName("should fail when shelf life is null for FINISHED_PRODUCT")
void should_Fail_When_ShelfLifeNullForFinishedProduct() { void should_Fail_When_ShelfLifeNullForFinishedProduct() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, null, "100", "KILOGRAM" "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, null, "100", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -147,7 +147,7 @@ class RecipeTest {
@DisplayName("should fail when shelf life is 0 for INTERMEDIATE") @DisplayName("should fail when shelf life is 0 for INTERMEDIATE")
void should_Fail_When_ShelfLifeZeroForIntermediate() { void should_Fail_When_ShelfLifeZeroForIntermediate() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Test", 1, RecipeType.INTERMEDIATE, null, 85, 0, "100", "KILOGRAM" "Test", 1, RecipeType.INTERMEDIATE, null, 85, 0, "100", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -160,7 +160,7 @@ class RecipeTest {
@DisplayName("should allow null shelf life for RAW_MATERIAL") @DisplayName("should allow null shelf life for RAW_MATERIAL")
void should_AllowNullShelfLife_When_RawMaterial() { void should_AllowNullShelfLife_When_RawMaterial() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Schweinefleisch", 1, RecipeType.RAW_MATERIAL, null, 100, null, "50", "KILOGRAM" "Schweinefleisch", 1, RecipeType.RAW_MATERIAL, null, 100, null, "50", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -173,7 +173,7 @@ class RecipeTest {
@DisplayName("should fail when output quantity is zero") @DisplayName("should fail when output quantity is zero")
void should_Fail_When_OutputQuantityZero() { void should_Fail_When_OutputQuantityZero() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "0", "KILOGRAM" "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "0", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -186,7 +186,7 @@ class RecipeTest {
@DisplayName("should fail when output UoM is invalid") @DisplayName("should fail when output UoM is invalid")
void should_Fail_When_OutputUomInvalid() { void should_Fail_When_OutputUomInvalid() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "INVALID" "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "INVALID", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -199,7 +199,7 @@ class RecipeTest {
@DisplayName("should allow description to be null") @DisplayName("should allow description to be null")
void should_AllowNullDescription() { void should_AllowNullDescription() {
var draft = new RecipeDraft( var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM", "article-123"
); );
var result = Recipe.create(draft); var result = Recipe.create(draft);
@ -238,7 +238,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
); );
@ -321,7 +321,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
); );
@ -375,7 +375,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
); );
@ -444,7 +444,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
); );
@ -506,7 +506,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
); );
@ -527,7 +527,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ARCHIVED, List.of(), List.of(), "article-123", RecipeStatus.ARCHIVED, List.of(), List.of(),
java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
); );
@ -553,7 +553,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), "article-123", RecipeStatus.ACTIVE, List.of(), List.of(),
java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
); );
var updatedBefore = recipe.updatedAt(); var updatedBefore = recipe.updatedAt();
@ -587,7 +587,7 @@ class RecipeTest {
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
null, new YieldPercentage(85), 14, null, new YieldPercentage(85), 14,
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ARCHIVED, List.of(), List.of(), "article-123", RecipeStatus.ARCHIVED, List.of(), List.of(),
java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
); );
@ -614,7 +614,7 @@ class RecipeTest {
recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL, recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL,
null, new YieldPercentage(100), null, null, new YieldPercentage(100), null,
Quantity.of(new java.math.BigDecimal("50"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new java.math.BigDecimal("50"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, List.of(), List.of(), recipe1.createdAt(), recipe1.updatedAt() "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), recipe1.createdAt(), recipe1.updatedAt()
); );
assertThat(recipe1).isEqualTo(recipe2); assertThat(recipe1).isEqualTo(recipe2);

View file

@ -312,7 +312,8 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest {
"yieldPercentage": 85, "yieldPercentage": 85,
"shelfLifeDays": 14, "shelfLifeDays": 14,
"outputQuantity": "100", "outputQuantity": "100",
"outputUom": "KILOGRAM" "outputUom": "KILOGRAM",
"articleId": "article-123"
} }
""".formatted(UUID.randomUUID().toString().substring(0, 8)); """.formatted(UUID.randomUUID().toString().substring(0, 8));

View file

@ -82,7 +82,8 @@ class GetRecipeIntegrationTest extends AbstractIntegrationTest {
"yieldPercentage": 85, "yieldPercentage": 85,
"shelfLifeDays": 14, "shelfLifeDays": 14,
"outputQuantity": "100", "outputQuantity": "100",
"outputUom": "KILOGRAM" "outputUom": "KILOGRAM",
"articleId": "article-123"
} }
"""; """;

View file

@ -113,7 +113,8 @@ class ListRecipesIntegrationTest extends AbstractIntegrationTest {
"yieldPercentage": 85, "yieldPercentage": 85,
"shelfLifeDays": 14, "shelfLifeDays": 14,
"outputQuantity": "100", "outputQuantity": "100",
"outputUom": "KILOGRAM" "outputUom": "KILOGRAM",
"articleId": "article-123"
} }
""".formatted(name, version); """.formatted(name, version);

View file

@ -0,0 +1,53 @@
# Backend: Atomarer Reorder-Endpoint für Rezept-Zutaten
## Kontext
Aktuell wird die Umsortierung von Rezept-Zutaten im Frontend durch sequentielles Entfernen und Neu-Hinzufügen aller Zutaten realisiert (Remove + Re-Add). Das ist fehleranfällig und nicht atomar.
## Anforderung
Neuer Endpoint:
```
PUT /api/recipes/{id}/ingredients/reorder
```
### Request Body
```json
{
"ingredientOrder": [
{ "ingredientId": "uuid-1", "position": 1 },
{ "ingredientId": "uuid-2", "position": 2 },
{ "ingredientId": "uuid-3", "position": 3 }
]
}
```
### Verhalten
- Alle `ingredientId`s muessen zum Rezept gehoeren
- Positionen muessen lueckenlos ab 1 aufsteigend sein
- Nur bei DRAFT-Status erlaubt
- Atomare Operation (eine Transaktion)
- Gibt das aktualisierte `RecipeResponse` zurueck
### Fehler
- `404` wenn Rezept nicht existiert
- `400` wenn IDs nicht zum Rezept gehoeren oder Positionen ungueltig
- `409` wenn Rezept nicht im DRAFT-Status
## Betroffene Schichten
- **Domain**: `Recipe.reorderIngredients(List<ReorderEntry>)` -> `Result<RecipeError, Void>`
- **Application**: `ReorderIngredients` Use Case + `ReorderIngredientsCommand`
- **Infrastructure**: Controller-Endpoint + Request-DTO
## Frontend-Anpassung
Nach Backend-Implementierung kann `RecipeDetailScreen.tsx` den Remove+Re-Add-Workaround durch einen einzelnen API-Call ersetzen:
```ts
await client.recipes.reorderIngredients(recipeId, { ingredientOrder: [...] });
```

View file

@ -6,8 +6,8 @@ import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js'; import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { SuccessDisplay } from '../shared/SuccessDisplay.js'; import { SuccessDisplay } from '../shared/SuccessDisplay.js';
import { useStocks } from '../../hooks/useStocks.js'; import { useStocks } from '../../hooks/useStocks.js';
import { BATCH_TYPE_LABELS } from '@effigenix/api-client'; import { BATCH_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from '@effigenix/api-client';
import type { BatchType } from '@effigenix/api-client'; import type { BatchType, UoM } from '@effigenix/api-client';
type Field = 'batchId' | 'batchType' | 'quantityAmount' | 'quantityUnit' | 'expiryDate'; type Field = 'batchId' | 'batchType' | 'quantityAmount' | 'quantityUnit' | 'expiryDate';
const FIELDS: Field[] = ['batchId', 'batchType', 'quantityAmount', 'quantityUnit', 'expiryDate']; const FIELDS: Field[] = ['batchId', 'batchType', 'quantityAmount', 'quantityUnit', 'expiryDate'];
@ -16,29 +16,65 @@ const FIELD_LABELS: Record<Field, string> = {
batchId: 'Chargen-Nr. *', batchId: 'Chargen-Nr. *',
batchType: 'Chargentyp *', batchType: 'Chargentyp *',
quantityAmount: 'Menge *', quantityAmount: 'Menge *',
quantityUnit: 'Einheit *', quantityUnit: 'Einheit * (←→ wechseln)',
expiryDate: 'Ablaufdatum (YYYY-MM-DD) *', expiryDate: 'Ablaufdatum (YYYY-MM-DD) *',
}; };
const BATCH_TYPES: BatchType[] = ['PURCHASED', 'PRODUCED']; const BATCH_TYPES: BatchType[] = ['PURCHASED', 'PRODUCED'];
export function AddBatchScreen() { export function AddBatchScreen() {
const { params, navigate, back } = useNavigation(); const { params, replace, back } = useNavigation();
const stockId = params['stockId'] ?? ''; const stockId = params['stockId'] ?? '';
const { addBatch, loading, error, clearError } = useStocks(); const { addBatch, loading, error, clearError } = useStocks();
const [values, setValues] = useState<Record<Field, string>>({ const [values, setValues] = useState<Record<Exclude<Field, 'quantityUnit'>, string>>({
batchId: '', batchType: 'PURCHASED', quantityAmount: '', quantityUnit: '', expiryDate: '', batchId: '', batchType: 'PURCHASED', quantityAmount: '', expiryDate: '',
}); });
const [uomIdx, setUomIdx] = useState(0);
const [activeField, setActiveField] = useState<Field>('batchId'); const [activeField, setActiveField] = useState<Field>('batchId');
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({}); const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const setField = (field: Field) => (value: string) => { const setField = (field: Exclude<Field, 'quantityUnit'>) => (value: string) => {
setValues((v) => ({ ...v, [field]: value })); setValues((v) => ({ ...v, [field]: value }));
}; };
const handleSubmit = async () => {
const errors: Partial<Record<Field, string>> = {};
if (!values.batchId.trim()) errors.batchId = 'Chargen-Nr. ist erforderlich.';
if (!values.quantityAmount.trim()) errors.quantityAmount = 'Menge ist erforderlich.';
if (values.quantityAmount.trim() && isNaN(Number(values.quantityAmount))) errors.quantityAmount = 'Muss eine Zahl sein.';
if (!values.expiryDate.trim()) errors.expiryDate = 'Ablaufdatum ist erforderlich.';
if (values.expiryDate.trim() && !/^\d{4}-\d{2}-\d{2}$/.test(values.expiryDate.trim())) {
errors.expiryDate = 'Format: YYYY-MM-DD';
}
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const selectedUom = UOM_VALUES[uomIdx] as UoM;
const batch = await addBatch(stockId, {
batchId: values.batchId.trim(),
batchType: values.batchType,
quantityAmount: values.quantityAmount.trim(),
quantityUnit: selectedUom,
expiryDate: values.expiryDate.trim(),
});
if (batch) {
setSuccess(`Charge ${batch.batchId} erfolgreich eingebucht.`);
}
};
const handleFieldSubmit = (field: Field) => (_value: string) => {
const idx = FIELDS.indexOf(field);
if (idx < FIELDS.length - 1) {
setActiveField(FIELDS[idx + 1] ?? field);
} else {
void handleSubmit();
}
};
useInput((_input, key) => { useInput((_input, key) => {
if (loading) return; if (loading) return;
@ -51,6 +87,22 @@ export function AddBatchScreen() {
if (next) setValues((v) => ({ ...v, batchType: next })); if (next) setValues((v) => ({ ...v, batchType: next }));
return; return;
} }
if (key.return) {
handleFieldSubmit('batchType')('');
return;
}
}
if (activeField === 'quantityUnit') {
if (key.leftArrow || key.rightArrow) {
const dir = key.rightArrow ? 1 : -1;
setUomIdx((i) => (i + dir + UOM_VALUES.length) % UOM_VALUES.length);
return;
}
if (key.return) {
handleFieldSubmit('quantityUnit')('');
return;
}
} }
if (key.tab || key.downArrow) { if (key.tab || key.downArrow) {
@ -68,49 +120,16 @@ export function AddBatchScreen() {
if (key.escape) back(); if (key.escape) back();
}); });
const handleSubmit = async () => {
const errors: Partial<Record<Field, string>> = {};
if (!values.batchId.trim()) errors.batchId = 'Chargen-Nr. ist erforderlich.';
if (!values.quantityAmount.trim()) errors.quantityAmount = 'Menge ist erforderlich.';
if (values.quantityAmount.trim() && isNaN(Number(values.quantityAmount))) errors.quantityAmount = 'Muss eine Zahl sein.';
if (!values.quantityUnit.trim()) errors.quantityUnit = 'Einheit ist erforderlich.';
if (!values.expiryDate.trim()) errors.expiryDate = 'Ablaufdatum ist erforderlich.';
if (values.expiryDate.trim() && !/^\d{4}-\d{2}-\d{2}$/.test(values.expiryDate.trim())) {
errors.expiryDate = 'Format: YYYY-MM-DD';
}
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const batch = await addBatch(stockId, {
batchId: values.batchId.trim(),
batchType: values.batchType,
quantityAmount: values.quantityAmount.trim(),
quantityUnit: values.quantityUnit.trim(),
expiryDate: values.expiryDate.trim(),
});
if (batch) {
setSuccess(`Charge ${batch.batchId} erfolgreich eingebucht.`);
}
};
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 (!stockId) return <ErrorDisplay message="Keine Bestand-ID vorhanden." onDismiss={back} />; if (!stockId) return <ErrorDisplay message="Keine Bestand-ID vorhanden." onDismiss={back} />;
if (loading) return <Box paddingY={2}><LoadingSpinner label="Charge wird eingebucht..." /></Box>; if (loading) return <Box paddingY={2}><LoadingSpinner label="Charge wird eingebucht..." /></Box>;
const uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx];
return ( return (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Charge einbuchen</Text> <Text color="cyan" bold>Charge einbuchen</Text>
{error && <ErrorDisplay message={error} onDismiss={clearError} />} {error && <ErrorDisplay message={error} onDismiss={clearError} />}
{success && <SuccessDisplay message={success} onDismiss={() => navigate('inventory-menu')} />} {success && <SuccessDisplay message={success} onDismiss={() => replace('inventory-menu')} />}
{!success && ( {!success && (
<Box flexDirection="column" gap={1} width={60}> <Box flexDirection="column" gap={1} width={60}>
@ -120,7 +139,16 @@ export function AddBatchScreen() {
return ( return (
<Box key={field} flexDirection="column"> <Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}> <Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: {activeField === field ? `${typeName}` : typeName} {FIELD_LABELS[field]}: {activeField === field ? `< ${typeName} >` : typeName}
</Text>
</Box>
);
}
if (field === 'quantityUnit') {
return (
<Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: <Text bold color="white">{activeField === field ? `< ${uomLabel} >` : uomLabel}</Text>
</Text> </Text>
</Box> </Box>
); );
@ -129,8 +157,8 @@ export function AddBatchScreen() {
<FormInput <FormInput
key={field} key={field}
label={FIELD_LABELS[field]} label={FIELD_LABELS[field]}
value={values[field]} value={values[field as Exclude<Field, 'quantityUnit'>]}
onChange={setField(field)} onChange={setField(field as Exclude<Field, 'quantityUnit'>)}
onSubmit={handleFieldSubmit(field)} onSubmit={handleFieldSubmit(field)}
focus={activeField === field} focus={activeField === field}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})} {...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
@ -142,7 +170,7 @@ export function AddBatchScreen() {
<Box marginTop={1}> <Box marginTop={1}>
<Text color="gray" dimColor> <Text color="gray" dimColor>
Tab/ Feld wechseln · Typ wählen · Enter auf letztem Feld speichern · Escape Abbrechen Tab/ Feld wechseln · Typ/Einheit wählen · Enter auf letztem Feld speichern · Escape Abbrechen
</Text> </Text>
</Box> </Box>
</Box> </Box>

View file

@ -14,7 +14,7 @@ export function StockBatchEntryScreen() {
}; };
useInput((_input, key) => { useInput((_input, key) => {
if (key.escape) back(); if (key.escape || key.backspace) back();
}); });
const handleSubmit = () => { const handleSubmit = () => {

View file

@ -21,7 +21,7 @@ const FIELD_LABELS: Record<Field, string> = {
const STORAGE_TYPES: StorageType[] = ['COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA']; const STORAGE_TYPES: StorageType[] = ['COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA'];
export function StorageLocationCreateScreen() { export function StorageLocationCreateScreen() {
const { navigate, back } = useNavigation(); const { replace, back } = useNavigation();
const { createStorageLocation, loading, error, clearError } = useStorageLocations(); const { createStorageLocation, loading, error, clearError } = useStorageLocations();
const [values, setValues] = useState<Record<Field, string>>({ const [values, setValues] = useState<Record<Field, string>>({
@ -78,7 +78,7 @@ export function StorageLocationCreateScreen() {
...(values.minTemperature.trim() ? { minTemperature: values.minTemperature.trim() } : {}), ...(values.minTemperature.trim() ? { minTemperature: values.minTemperature.trim() } : {}),
...(values.maxTemperature.trim() ? { maxTemperature: values.maxTemperature.trim() } : {}), ...(values.maxTemperature.trim() ? { maxTemperature: values.maxTemperature.trim() } : {}),
}); });
if (result) navigate('storage-location-list'); if (result) replace('storage-location-list');
}; };
const handleFieldSubmit = (field: Field) => (_value: string) => { const handleFieldSubmit = (field: Field) => (_value: string) => {

View file

@ -17,7 +17,7 @@ const UNIT_PRICE_MODEL: Record<Unit, PriceModel> = {
}; };
export function AddSalesUnitScreen() { export function AddSalesUnitScreen() {
const { params, navigate, back } = useNavigation(); const { params, replace, back } = useNavigation();
const articleId = params['articleId'] ?? ''; const articleId = params['articleId'] ?? '';
const { addSalesUnit, loading, error, clearError } = useArticles(); const { addSalesUnit, loading, error, clearError } = useArticles();
@ -50,7 +50,7 @@ export function AddSalesUnitScreen() {
} }
setPriceError(null); setPriceError(null);
const updated = await addSalesUnit(articleId, { unit: selectedUnit, priceModel: autoModel, price: priceNum }); const updated = await addSalesUnit(articleId, { unit: selectedUnit, priceModel: autoModel, price: priceNum });
if (updated) navigate('article-detail', { articleId }); if (updated) replace('article-detail', { articleId });
}; };
if (loading) return <Box paddingY={2}><LoadingSpinner label="Verkaufseinheit wird hinzugefügt..." /></Box>; if (loading) return <Box paddingY={2}><LoadingSpinner label="Verkaufseinheit wird hinzugefügt..." /></Box>;

View file

@ -23,7 +23,7 @@ const UNIT_PRICE_MODEL: Record<Unit, PriceModel> = {
}; };
export function ArticleCreateScreen() { export function ArticleCreateScreen() {
const { navigate, back } = useNavigation(); const { replace, back } = useNavigation();
const { createArticle, loading, error, clearError } = useArticles(); const { createArticle, loading, error, clearError } = useArticles();
const { categories, fetchCategories } = useCategories(); const { categories, fetchCategories } = useCategories();
@ -94,7 +94,7 @@ export function ArticleCreateScreen() {
priceModel: autoModel, priceModel: autoModel,
price: priceNum, price: priceNum,
}); });
if (result) navigate('article-list'); if (result) replace('article-list');
}; };
if (loading) return <Box paddingY={2}><LoadingSpinner label="Artikel wird angelegt..." /></Box>; if (loading) return <Box paddingY={2}><LoadingSpinner label="Artikel wird angelegt..." /></Box>;

View file

@ -10,7 +10,7 @@ type Field = 'name' | 'description';
const FIELDS: Field[] = ['name', 'description']; const FIELDS: Field[] = ['name', 'description'];
export function CategoryCreateScreen() { export function CategoryCreateScreen() {
const { navigate, back } = useNavigation(); const { replace, back } = useNavigation();
const { createCategory, loading, error, clearError } = useCategories(); const { createCategory, loading, error, clearError } = useCategories();
const [name, setName] = useState(''); const [name, setName] = useState('');
@ -42,7 +42,7 @@ export function CategoryCreateScreen() {
return; return;
} }
const cat = await createCategory(name.trim(), description.trim() || undefined); const cat = await createCategory(name.trim(), description.trim() || undefined);
if (cat) navigate('category-list'); if (cat) replace('category-list');
}; };
const handleFieldSubmit = (field: Field) => (_value: string) => { const handleFieldSubmit = (field: Field) => (_value: string) => {

View file

@ -21,7 +21,7 @@ const FIELD_LABELS: Record<Field, string> = {
}; };
export function AddDeliveryAddressScreen() { export function AddDeliveryAddressScreen() {
const { params, navigate, back } = useNavigation(); const { params, replace, back } = useNavigation();
const customerId = params['customerId'] ?? ''; const customerId = params['customerId'] ?? '';
const { addDeliveryAddress, loading, error, clearError } = useCustomers(); const { addDeliveryAddress, loading, error, clearError } = useCustomers();
@ -74,7 +74,7 @@ export function AddDeliveryAddressScreen() {
...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.trim() } : {}), ...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.trim() } : {}),
...(values.deliveryNotes.trim() ? { deliveryNotes: values.deliveryNotes.trim() } : {}), ...(values.deliveryNotes.trim() ? { deliveryNotes: values.deliveryNotes.trim() } : {}),
}); });
if (updated) navigate('customer-detail', { customerId }); if (updated) replace('customer-detail', { customerId });
}; };
const handleFieldSubmit = (field: Field) => (_value: string) => { const handleFieldSubmit = (field: Field) => (_value: string) => {

View file

@ -25,7 +25,7 @@ const FIELD_LABELS: Record<Field, string> = {
const TYPES: CustomerType[] = ['B2B', 'B2C']; const TYPES: CustomerType[] = ['B2B', 'B2C'];
export function CustomerCreateScreen() { export function CustomerCreateScreen() {
const { navigate, back } = useNavigation(); const { replace, back } = useNavigation();
const { createCustomer, loading, error, clearError } = useCustomers(); const { createCustomer, loading, error, clearError } = useCustomers();
const [values, setValues] = useState<Record<Field, string>>({ const [values, setValues] = useState<Record<Field, string>>({
@ -91,7 +91,7 @@ export function CustomerCreateScreen() {
...(values.email.trim() ? { email: values.email.trim() } : {}), ...(values.email.trim() ? { email: values.email.trim() } : {}),
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}), ...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
}); });
if (result) navigate('customer-list'); if (result) replace('customer-list');
}; };
const handleFieldSubmit = (field: Field) => (_value: string) => { const handleFieldSubmit = (field: Field) => (_value: string) => {

View file

@ -17,7 +17,7 @@ function errorMessage(err: unknown): string {
} }
export function SetPreferencesScreen() { export function SetPreferencesScreen() {
const { params, navigate, back } = useNavigation(); const { params, replace, back } = useNavigation();
const customerId = params['customerId'] ?? ''; const customerId = params['customerId'] ?? '';
const { setPreferences, loading, error, clearError } = useCustomers(); const { setPreferences, loading, error, clearError } = useCustomers();
@ -53,8 +53,8 @@ export function SetPreferencesScreen() {
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
const updated = await setPreferences(customerId, Array.from(checked)); const updated = await setPreferences(customerId, Array.from(checked));
if (updated) navigate('customer-detail', { customerId }); if (updated) replace('customer-detail', { customerId });
}, [customerId, checked, setPreferences, navigate]); }, [customerId, checked, setPreferences, replace]);
if (initLoading) return <LoadingSpinner label="Lade Präferenzen..." />; if (initLoading) return <LoadingSpinner label="Lade Präferenzen..." />;
if (initError) return <ErrorDisplay message={initError} onDismiss={back} />; if (initError) return <ErrorDisplay message={initError} onDismiss={back} />;

View file

@ -21,7 +21,7 @@ function isValidDate(s: string): boolean {
} }
export function AddCertificateScreen() { export function AddCertificateScreen() {
const { params, navigate, back } = useNavigation(); const { params, replace, back } = useNavigation();
const supplierId = params['supplierId'] ?? ''; const supplierId = params['supplierId'] ?? '';
const { addCertificate, loading, error, clearError } = useSuppliers(); const { addCertificate, loading, error, clearError } = useSuppliers();
@ -70,7 +70,7 @@ export function AddCertificateScreen() {
validFrom: values.validFrom, validFrom: values.validFrom,
validUntil: values.validUntil, validUntil: values.validUntil,
}); });
if (updated) navigate('supplier-detail', { supplierId }); if (updated) replace('supplier-detail', { supplierId });
}; };
const handleFieldSubmit = (field: Field) => (_value: string) => { const handleFieldSubmit = (field: Field) => (_value: string) => {

View file

@ -32,7 +32,7 @@ function ScoreSelector({ label, value, active }: { label: string; value: number;
} }
export function RateSupplierScreen() { export function RateSupplierScreen() {
const { params, navigate, back } = useNavigation(); const { params, replace, back } = useNavigation();
const supplierId = params['supplierId'] ?? ''; const supplierId = params['supplierId'] ?? '';
const { rateSupplier, loading, error, clearError } = useSuppliers(); const { rateSupplier, loading, error, clearError } = useSuppliers();
@ -81,7 +81,7 @@ export function RateSupplierScreen() {
}); });
if (updated) { if (updated) {
setSuccessMessage('Bewertung gespeichert.'); setSuccessMessage('Bewertung gespeichert.');
setTimeout(() => navigate('supplier-detail', { supplierId }), 1000); setTimeout(() => replace('supplier-detail', { supplierId }), 1000);
} }
}; };

View file

@ -23,7 +23,7 @@ const FIELD_LABELS: Record<Field, string> = {
}; };
export function SupplierCreateScreen() { export function SupplierCreateScreen() {
const { navigate, back } = useNavigation(); const { replace, back } = useNavigation();
const { createSupplier, loading, error, clearError } = useSuppliers(); const { createSupplier, loading, error, clearError } = useSuppliers();
const [values, setValues] = useState<Record<Field, string>>({ const [values, setValues] = useState<Record<Field, string>>({
@ -74,7 +74,7 @@ export function SupplierCreateScreen() {
...(values.country.trim() ? { country: values.country.trim() } : {}), ...(values.country.trim() ? { country: values.country.trim() } : {}),
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}), ...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
}); });
if (result) navigate('supplier-list'); if (result) replace('supplier-list');
}; };
const handleFieldSubmit = (field: Field) => (_value: string) => { const handleFieldSubmit = (field: Field) => (_value: string) => {

View file

@ -1,19 +1,22 @@
import { useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js'; import { useNavigation } from '../../state/navigation-context.js';
import { FormInput } from '../shared/FormInput.js'; import { FormInput } from '../shared/FormInput.js';
import { ArticlePicker } from '../shared/ArticlePicker.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js'; import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js'; import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { useArticles } from '../../hooks/useArticles.js';
import { UOM_VALUES, UOM_LABELS } from '@effigenix/api-client';
import type { UoM, RecipeDTO, ArticleDTO } from '@effigenix/api-client';
import { client } from '../../utils/api-client.js'; import { client } from '../../utils/api-client.js';
type Field = 'position' | 'articleId' | 'quantity' | 'uom' | 'subRecipeId' | 'substitutable'; type Field = 'articleId' | 'quantity' | 'uom' | 'subRecipeId' | 'substitutable';
const FIELDS: Field[] = ['position', 'articleId', 'quantity', 'uom', 'subRecipeId', 'substitutable']; const FIELDS: Field[] = ['articleId', 'quantity', 'uom', 'subRecipeId', 'substitutable'];
const FIELD_LABELS: Record<Field, string> = { const FIELD_LABELS: Record<Field, string> = {
position: 'Position (optional)', articleId: 'Artikel *',
articleId: 'Artikel-ID *',
quantity: 'Menge *', quantity: 'Menge *',
uom: 'Einheit *', uom: 'Einheit * (←→ wechseln)',
subRecipeId: 'Sub-Rezept-ID (optional)', subRecipeId: 'Sub-Rezept-ID (optional)',
substitutable: 'Austauschbar (ja/nein, optional)', substitutable: 'Austauschbar (ja/nein, optional)',
}; };
@ -23,65 +26,81 @@ function errorMessage(err: unknown): string {
} }
export function AddIngredientScreen() { export function AddIngredientScreen() {
const { params, navigate, back } = useNavigation(); const { params, replace, back } = useNavigation();
const recipeId = params['recipeId'] ?? ''; const recipeId = params['recipeId'] ?? '';
const [values, setValues] = useState<Record<Field, string>>({ const { articles, fetchArticles } = useArticles();
position: '', articleId: '', quantity: '', uom: '', subRecipeId: '', substitutable: '',
const [recipe, setRecipe] = useState<RecipeDTO | null>(null);
const [recipeLoading, setRecipeLoading] = useState(true);
const [values, setValues] = useState({
quantity: '',
subRecipeId: '',
substitutable: '',
}); });
const [activeField, setActiveField] = useState<Field>('position'); const [uomIdx, setUomIdx] = useState(0);
const [articleQuery, setArticleQuery] = useState('');
const [selectedArticle, setSelectedArticle] = useState<{ id: string; name: string; articleNumber: string } | null>(null);
const [activeField, setActiveField] = useState<Field>('articleId');
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({}); const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const setField = (field: Field) => (value: string) => { useEffect(() => {
if (!recipeId) return;
void fetchArticles();
client.recipes.getById(recipeId)
.then((r) => { setRecipe(r); setRecipeLoading(false); })
.catch((err: unknown) => { setError(errorMessage(err)); setRecipeLoading(false); });
}, [recipeId, fetchArticles]);
const setField = (field: 'quantity' | 'subRecipeId' | 'substitutable') => (value: string) => {
setValues((v) => ({ ...v, [field]: value })); setValues((v) => ({ ...v, [field]: value }));
}; };
useInput((_input, key) => { const handleArticleSelect = useCallback((article: ArticleDTO) => {
if (loading) return; setSelectedArticle({ id: article.id, name: article.name, articleNumber: article.articleNumber });
if (key.tab || key.downArrow) { setArticleQuery('');
setActiveField((f) => { setActiveField('quantity');
const idx = FIELDS.indexOf(f);
return FIELDS[(idx + 1) % FIELDS.length] ?? f; void client.recipes.list().then((recipes) => {
}); const linked = recipes.find((r) => r.articleId === article.id && r.status === 'ACTIVE')
} ?? recipes.find((r) => r.articleId === article.id);
if (key.upArrow) { if (linked) {
setActiveField((f) => { setValues((v) => ({ ...v, subRecipeId: linked.id }));
const idx = FIELDS.indexOf(f); }
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f; }).catch(() => { /* Rezept-Lookup fehlgeschlagen subRecipeId bleibt leer */ });
}); }, []);
}
if (key.escape) back();
});
const handleSubmit = async () => { const handleSubmit = async () => {
const errors: Partial<Record<Field, string>> = {}; const errors: Partial<Record<Field, string>> = {};
if (!values.articleId.trim()) errors.articleId = 'Artikel-ID ist erforderlich.'; if (!selectedArticle) errors.articleId = 'Artikel ist erforderlich.';
if (!values.quantity.trim()) errors.quantity = 'Menge ist erforderlich.'; if (!values.quantity.trim()) errors.quantity = 'Menge ist erforderlich.';
if (values.quantity.trim() && isNaN(Number(values.quantity))) errors.quantity = 'Muss eine Zahl sein.'; if (values.quantity.trim() && isNaN(Number(values.quantity))) errors.quantity = 'Muss eine Zahl sein.';
if (!values.uom.trim()) errors.uom = 'Einheit ist erforderlich.';
if (values.position.trim() && (!Number.isInteger(Number(values.position)) || Number(values.position) < 1)) {
errors.position = 'Muss eine positive Ganzzahl sein.';
}
if (values.substitutable.trim() && !['ja', 'nein'].includes(values.substitutable.trim().toLowerCase())) { if (values.substitutable.trim() && !['ja', 'nein'].includes(values.substitutable.trim().toLowerCase())) {
errors.substitutable = 'Muss "ja" oder "nein" sein.'; errors.substitutable = 'Muss "ja" oder "nein" sein.';
} }
setFieldErrors(errors); setFieldErrors(errors);
if (Object.keys(errors).length > 0) return; if (Object.keys(errors).length > 0) return;
const maxPosition = recipe?.ingredients?.length
? Math.max(...recipe.ingredients.map((i) => i.position))
: 0;
const selectedUom = UOM_VALUES[uomIdx] as UoM;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
await client.recipes.addIngredient(recipeId, { await client.recipes.addIngredient(recipeId, {
articleId: values.articleId.trim(), articleId: selectedArticle!.id,
quantity: values.quantity.trim(), quantity: values.quantity.trim(),
uom: values.uom.trim(), uom: selectedUom,
...(values.position.trim() ? { position: Number(values.position) } : {}), position: maxPosition + 1,
...(values.subRecipeId.trim() ? { subRecipeId: values.subRecipeId.trim() } : {}), ...(values.subRecipeId.trim() ? { subRecipeId: values.subRecipeId.trim() } : {}),
...(values.substitutable.trim() ? { substitutable: values.substitutable.trim().toLowerCase() === 'ja' } : {}), ...(values.substitutable.trim() ? { substitutable: values.substitutable.trim().toLowerCase() === 'ja' } : {}),
}); });
navigate('recipe-detail', { recipeId }); replace('recipe-detail', { recipeId });
} catch (err: unknown) { } catch (err: unknown) {
setError(errorMessage(err)); setError(errorMessage(err));
setLoading(false); setLoading(false);
@ -97,31 +116,93 @@ export function AddIngredientScreen() {
} }
}; };
useInput((_input, key) => {
if (loading || recipeLoading) return;
if (activeField === 'articleId') return;
if (activeField === 'uom') {
if (key.leftArrow || key.rightArrow) {
const dir = key.rightArrow ? 1 : -1;
setUomIdx((i) => (i + dir + UOM_VALUES.length) % UOM_VALUES.length);
return;
}
if (key.return) {
handleFieldSubmit('uom')('');
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();
});
if (!recipeId) return <ErrorDisplay message="Keine Rezept-ID vorhanden." onDismiss={back} />; if (!recipeId) return <ErrorDisplay message="Keine Rezept-ID vorhanden." onDismiss={back} />;
if (recipeLoading) return <Box paddingY={2}><LoadingSpinner label="Lade Rezept..." /></Box>;
if (loading) return <Box paddingY={2}><LoadingSpinner label="Zutat wird hinzugefügt..." /></Box>; if (loading) return <Box paddingY={2}><LoadingSpinner label="Zutat wird hinzugefügt..." /></Box>;
const uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx];
const selectedName = selectedArticle ? `${selectedArticle.name} (${selectedArticle.articleNumber})` : undefined;
return ( return (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Zutat hinzufügen</Text> <Text color="cyan" bold>Zutat hinzufügen</Text>
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />} {error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
<Box flexDirection="column" gap={1} width={60}> <Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => ( {FIELDS.map((field) => {
<FormInput if (field === 'articleId') {
key={field} return (
label={FIELD_LABELS[field]} <Box key={field} flexDirection="column">
value={values[field]} <ArticlePicker
onChange={setField(field)} articles={articles}
onSubmit={handleFieldSubmit(field)} query={articleQuery}
focus={activeField === field} onQueryChange={setArticleQuery}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})} onSelect={handleArticleSelect}
/> focus={activeField === 'articleId'}
))} {...(selectedName ? { selectedName } : {})}
/>
{fieldErrors.articleId && <Text color="red"> {fieldErrors.articleId}</Text>}
</Box>
);
}
if (field === 'uom') {
return (
<Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: <Text bold color="white">{activeField === field ? `< ${uomLabel} >` : uomLabel}</Text>
</Text>
</Box>
);
}
const textField = field as 'quantity' | 'subRecipeId' | 'substitutable';
return (
<FormInput
key={field}
label={FIELD_LABELS[field]}
value={values[textField]}
onChange={setField(textField)}
onSubmit={handleFieldSubmit(field)}
focus={activeField === field}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
/>
);
})}
</Box> </Box>
<Box marginTop={1}> <Box marginTop={1}>
<Text color="gray" dimColor> <Text color="gray" dimColor>
Tab/ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen Tab/ Feld wechseln · Einheit · Artikelsuche tippen · Enter auf letztem Feld speichern · Escape Abbrechen
</Text> </Text>
</Box> </Box>
</Box> </Box>

View file

@ -21,7 +21,7 @@ function errorMessage(err: unknown): string {
} }
export function AddProductionStepScreen() { export function AddProductionStepScreen() {
const { params, navigate, back } = useNavigation(); const { params, replace, back } = useNavigation();
const recipeId = params['recipeId'] ?? ''; const recipeId = params['recipeId'] ?? '';
const [values, setValues] = useState<Record<Field, string>>({ const [values, setValues] = useState<Record<Field, string>>({
@ -77,7 +77,7 @@ export function AddProductionStepScreen() {
...(values.durationMinutes.trim() ? { durationMinutes: Number(values.durationMinutes) } : {}), ...(values.durationMinutes.trim() ? { durationMinutes: Number(values.durationMinutes) } : {}),
...(values.temperatureCelsius.trim() ? { temperatureCelsius: Number(values.temperatureCelsius) } : {}), ...(values.temperatureCelsius.trim() ? { temperatureCelsius: Number(values.temperatureCelsius) } : {}),
}); });
navigate('recipe-detail', { recipeId }); replace('recipe-detail', { recipeId });
} catch (err: unknown) { } catch (err: unknown) {
setError(errorMessage(err)); setError(errorMessage(err));
setLoading(false); setLoading(false);

View file

@ -1,15 +1,17 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js'; import { useNavigation } from '../../state/navigation-context.js';
import { useRecipes } from '../../hooks/useRecipes.js'; import { useRecipes } from '../../hooks/useRecipes.js';
import { useArticles } from '../../hooks/useArticles.js';
import { FormInput } from '../shared/FormInput.js'; import { FormInput } from '../shared/FormInput.js';
import { ArticlePicker } from '../shared/ArticlePicker.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js'; import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js'; import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { RECIPE_TYPE_LABELS } from '@effigenix/api-client'; import { RECIPE_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from '@effigenix/api-client';
import type { RecipeType } from '@effigenix/api-client'; import type { RecipeType, UoM, ArticleDTO } from '@effigenix/api-client';
type Field = 'name' | 'version' | 'type' | 'description' | 'yieldPercentage' | 'shelfLifeDays' | 'outputQuantity' | 'outputUom'; type Field = 'name' | 'version' | 'type' | 'description' | 'yieldPercentage' | 'shelfLifeDays' | 'outputQuantity' | 'outputUom' | 'articleId';
const FIELDS: Field[] = ['name', 'version', 'type', 'description', 'yieldPercentage', 'shelfLifeDays', 'outputQuantity', 'outputUom']; const FIELDS: Field[] = ['name', 'version', 'type', 'description', 'yieldPercentage', 'shelfLifeDays', 'outputQuantity', 'outputUom', 'articleId'];
const FIELD_LABELS: Record<Field, string> = { const FIELD_LABELS: Record<Field, string> = {
name: 'Name *', name: 'Name *',
@ -19,16 +21,20 @@ const FIELD_LABELS: Record<Field, string> = {
yieldPercentage: 'Ausbeute (%) *', yieldPercentage: 'Ausbeute (%) *',
shelfLifeDays: 'Haltbarkeit (Tage)', shelfLifeDays: 'Haltbarkeit (Tage)',
outputQuantity: 'Ausgabemenge *', outputQuantity: 'Ausgabemenge *',
outputUom: 'Mengeneinheit *', outputUom: 'Mengeneinheit * (←→ wechseln)',
articleId: 'Artikel *',
}; };
const RECIPE_TYPES: RecipeType[] = ['RAW_MATERIAL', 'INTERMEDIATE', 'FINISHED_PRODUCT']; const RECIPE_TYPES: RecipeType[] = ['RAW_MATERIAL', 'INTERMEDIATE', 'FINISHED_PRODUCT'];
export function RecipeCreateScreen() { type TextFields = Exclude<Field, 'outputUom' | 'articleId'>;
const { navigate, back } = useNavigation();
const { createRecipe, loading, error, clearError } = useRecipes();
const [values, setValues] = useState<Record<Field, string>>({ export function RecipeCreateScreen() {
const { replace, back } = useNavigation();
const { createRecipe, loading, error, clearError } = useRecipes();
const { articles, fetchArticles } = useArticles();
const [values, setValues] = useState<Record<TextFields, string>>({
name: '', name: '',
version: '1', version: '1',
type: 'FINISHED_PRODUCT', type: 'FINISHED_PRODUCT',
@ -36,17 +42,68 @@ export function RecipeCreateScreen() {
yieldPercentage: '100', yieldPercentage: '100',
shelfLifeDays: '', shelfLifeDays: '',
outputQuantity: '', outputQuantity: '',
outputUom: '',
}); });
const [uomIdx, setUomIdx] = useState(0);
const [articleQuery, setArticleQuery] = useState('');
const [selectedArticle, setSelectedArticle] = useState<{ id: string; name: string } | null>(null);
const [activeField, setActiveField] = useState<Field>('name'); const [activeField, setActiveField] = useState<Field>('name');
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({}); const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
const setField = (field: Field) => (value: string) => { useEffect(() => { void fetchArticles(); }, []);
const setField = (field: TextFields) => (value: string) => {
setValues((v) => ({ ...v, [field]: value })); setValues((v) => ({ ...v, [field]: value }));
}; };
useInput((input, key) => { const handleSubmit = async () => {
const errors: Partial<Record<Field, string>> = {};
if (!values.name.trim()) errors.name = 'Name ist erforderlich.';
if (!values.version.trim() || isNaN(parseInt(values.version, 10))) errors.version = 'Version muss eine Zahl sein.';
if (!values.type) errors.type = 'Rezepttyp ist erforderlich.';
if (!values.yieldPercentage.trim() || isNaN(parseInt(values.yieldPercentage, 10))) errors.yieldPercentage = 'Ausbeute muss eine Zahl sein.';
if (!values.outputQuantity.trim()) errors.outputQuantity = 'Ausgabemenge ist erforderlich.';
if (!selectedArticle) errors.articleId = 'Artikel muss ausgewählt werden.';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const selectedUom = UOM_VALUES[uomIdx] as UoM;
const result = await createRecipe({
name: values.name.trim(),
version: parseInt(values.version, 10),
type: values.type as RecipeType,
...(values.description.trim() ? { description: values.description.trim() } : {}),
yieldPercentage: parseInt(values.yieldPercentage, 10),
...(values.shelfLifeDays.trim() ? { shelfLifeDays: parseInt(values.shelfLifeDays, 10) } : {}),
outputQuantity: values.outputQuantity.trim(),
outputUom: selectedUom,
articleId: selectedArticle!.id,
});
if (result) replace('recipe-list');
};
const handleFieldSubmit = (field: Field) => (_value: string) => {
const idx = FIELDS.indexOf(field);
if (idx < FIELDS.length - 1) {
setActiveField(FIELDS[idx + 1] ?? field);
} else {
void handleSubmit();
}
};
const handleArticleSelect = (article: ArticleDTO) => {
setSelectedArticle({ id: article.id, name: `${article.name} (${article.articleNumber})` });
setArticleQuery('');
};
useInput((_input, key) => {
if (loading) return; if (loading) return;
if (activeField === 'articleId') {
if (key.escape) back();
if (key.tab || key.downArrow) setActiveField(FIELDS[0] ?? 'name');
if (key.upArrow) setActiveField(FIELDS[FIELDS.length - 2] ?? 'outputUom');
if (key.return && selectedArticle && !articleQuery) void handleSubmit();
return;
}
if (activeField === 'type') { if (activeField === 'type') {
if (key.leftArrow || key.rightArrow) { if (key.leftArrow || key.rightArrow) {
@ -56,6 +113,22 @@ export function RecipeCreateScreen() {
if (next) setValues((v) => ({ ...v, type: next })); if (next) setValues((v) => ({ ...v, type: next }));
return; return;
} }
if (key.return) {
handleFieldSubmit('type')('');
return;
}
}
if (activeField === 'outputUom') {
if (key.leftArrow || key.rightArrow) {
const dir = key.rightArrow ? 1 : -1;
setUomIdx((i) => (i + dir + UOM_VALUES.length) % UOM_VALUES.length);
return;
}
if (key.return) {
handleFieldSubmit('outputUom')('');
return;
}
} }
if (key.tab || key.downArrow) { if (key.tab || key.downArrow) {
@ -73,39 +146,6 @@ export function RecipeCreateScreen() {
if (key.escape) back(); if (key.escape) back();
}); });
const handleSubmit = async () => {
const errors: Partial<Record<Field, string>> = {};
if (!values.name.trim()) errors.name = 'Name ist erforderlich.';
if (!values.version.trim() || isNaN(parseInt(values.version, 10))) errors.version = 'Version muss eine Zahl sein.';
if (!values.type) errors.type = 'Rezepttyp ist erforderlich.';
if (!values.yieldPercentage.trim() || isNaN(parseInt(values.yieldPercentage, 10))) errors.yieldPercentage = 'Ausbeute muss eine Zahl sein.';
if (!values.outputQuantity.trim()) errors.outputQuantity = 'Ausgabemenge ist erforderlich.';
if (!values.outputUom.trim()) errors.outputUom = 'Mengeneinheit ist erforderlich.';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const result = await createRecipe({
name: values.name.trim(),
version: parseInt(values.version, 10),
type: values.type as RecipeType,
...(values.description.trim() ? { description: values.description.trim() } : {}),
yieldPercentage: parseInt(values.yieldPercentage, 10),
...(values.shelfLifeDays.trim() ? { shelfLifeDays: parseInt(values.shelfLifeDays, 10) } : {}),
outputQuantity: values.outputQuantity.trim(),
outputUom: values.outputUom.trim(),
});
if (result) navigate('recipe-list');
};
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 (loading) { if (loading) {
return ( return (
<Box flexDirection="column" alignItems="center" paddingY={2}> <Box flexDirection="column" alignItems="center" paddingY={2}>
@ -115,6 +155,7 @@ export function RecipeCreateScreen() {
} }
const typeLabel = RECIPE_TYPE_LABELS[values.type as RecipeType] ?? values.type; const typeLabel = RECIPE_TYPE_LABELS[values.type as RecipeType] ?? values.type;
const uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx];
return ( return (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
@ -127,18 +168,43 @@ export function RecipeCreateScreen() {
return ( return (
<Box key={field} flexDirection="column"> <Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}> <Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: <Text bold color="white">{typeLabel}</Text> {FIELD_LABELS[field]}: <Text bold color="white">{activeField === field ? `< ${typeLabel} >` : typeLabel}</Text>
</Text> </Text>
{fieldErrors[field] && <Text color="red">{fieldErrors[field]}</Text>} {fieldErrors[field] && <Text color="red">{fieldErrors[field]}</Text>}
</Box> </Box>
); );
} }
if (field === 'outputUom') {
return (
<Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: <Text bold color="white">{activeField === field ? `< ${uomLabel} >` : uomLabel}</Text>
</Text>
</Box>
);
}
if (field === 'articleId') {
return (
<Box key={field} flexDirection="column">
<ArticlePicker
articles={articles}
query={articleQuery}
onQueryChange={setArticleQuery}
onSelect={handleArticleSelect}
focus={activeField === 'articleId'}
{...(selectedArticle ? { selectedName: selectedArticle.name } : {})}
/>
{fieldErrors[field] && <Text color="red">{fieldErrors[field]}</Text>}
</Box>
);
}
const tf = field as TextFields;
return ( return (
<FormInput <FormInput
key={field} key={field}
label={FIELD_LABELS[field]} label={FIELD_LABELS[field]}
value={values[field]} value={values[tf]}
onChange={setField(field)} onChange={setField(tf)}
onSubmit={handleFieldSubmit(field)} onSubmit={handleFieldSubmit(field)}
focus={activeField === field} focus={activeField === field}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})} {...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
@ -149,7 +215,7 @@ export function RecipeCreateScreen() {
<Box marginTop={1}> <Box marginTop={1}>
<Text color="gray" dimColor> <Text color="gray" dimColor>
Tab/ Feld wechseln · Rezepttyp · Enter auf letztem Feld speichern · Escape Abbrechen Tab/ Feld wechseln · Rezepttyp/Einheit · Artikel: Suche tippen, Enter auswählen, nochmal Enter speichern · Escape Abbrechen
</Text> </Text>
</Box> </Box>
</Box> </Box>

View file

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink'; 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 { RECIPE_TYPE_LABELS } from '@effigenix/api-client';
import { useNavigation } from '../../state/navigation-context.js'; import { useNavigation } from '../../state/navigation-context.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js'; import { LoadingSpinner } from '../shared/LoadingSpinner.js';
@ -9,8 +9,8 @@ import { SuccessDisplay } from '../shared/SuccessDisplay.js';
import { ConfirmDialog } from '../shared/ConfirmDialog.js'; import { ConfirmDialog } from '../shared/ConfirmDialog.js';
import { client } from '../../utils/api-client.js'; import { client } from '../../utils/api-client.js';
type MenuAction = 'add-ingredient' | 'remove-ingredient' | 'add-step' | 'remove-step' | 'activate' | 'archive' | 'back'; 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'; 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 { function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler'; return err instanceof Error ? err.message : 'Unbekannter Fehler';
@ -31,6 +31,13 @@ export function RecipeDetailScreen() {
const [stepToRemove, setStepToRemove] = useState<ProductionStepDTO | null>(null); const [stepToRemove, setStepToRemove] = useState<ProductionStepDTO | null>(null);
const [selectedIngredientIndex, setSelectedIngredientIndex] = useState(0); const [selectedIngredientIndex, setSelectedIngredientIndex] = useState(0);
const [ingredientToRemove, setIngredientToRemove] = useState<IngredientDTO | null>(null); 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 isDraft = recipe?.status === 'DRAFT';
const isActive = recipe?.status === 'ACTIVE'; const isActive = recipe?.status === 'ACTIVE';
@ -39,6 +46,9 @@ export function RecipeDetailScreen() {
...(isDraft ? [ ...(isDraft ? [
{ id: 'add-ingredient' as const, label: '[Zutat hinzufügen]' }, { id: 'add-ingredient' as const, label: '[Zutat hinzufügen]' },
{ id: 'remove-ingredient' as const, label: '[Zutat entfernen]' }, { 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: 'add-step' as const, label: '[Schritt hinzufügen]' },
{ id: 'remove-step' as const, label: '[Schritt entfernen]' }, { id: 'remove-step' as const, label: '[Schritt entfernen]' },
{ id: 'activate' as const, label: '[Rezept aktivieren]' }, { id: 'activate' as const, label: '[Rezept aktivieren]' },
@ -55,16 +65,25 @@ export function RecipeDetailScreen() {
? [...recipe.productionSteps].sort((a, b) => a.stepNumber - b.stepNumber) ? [...recipe.productionSteps].sort((a, b) => a.stepNumber - b.stepNumber)
: []; : [];
const sortedIngredients = recipe?.ingredients const sortedIngredients = sortedIngredientsOf(recipe);
? [...recipe.ingredients].sort((a, b) => a.position - b.position)
: [];
const loadRecipe = useCallback(() => { const loadRecipe = useCallback(() => {
setLoading(true); setLoading(true);
setError(null); setError(null);
client.recipes.getById(recipeId) Promise.all([
.then((r) => { setRecipe(r); setLoading(false); }) client.recipes.getById(recipeId),
.catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); }); 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]); }, [recipeId]);
useEffect(() => { if (recipeId) loadRecipe(); }, [loadRecipe, recipeId]); useEffect(() => { if (recipeId) loadRecipe(); }, [loadRecipe, recipeId]);
@ -98,6 +117,62 @@ export function RecipeDetailScreen() {
} }
if (key.escape) { setMode('menu'); setSelectedIngredientIndex(0); } 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 () => { const handleAction = async () => {
@ -117,6 +192,12 @@ export function RecipeDetailScreen() {
setSelectedIngredientIndex(0); setSelectedIngredientIndex(0);
setMode('select-ingredient-to-remove'); setMode('select-ingredient-to-remove');
break; break;
case 'reorder-ingredients':
setReorderList([...sortedIngredients]);
setReorderCursor(0);
setReorderHeld(null);
setMode('reorder-ingredient');
break;
case 'add-step': case 'add-step':
navigate('recipe-add-production-step', { recipeId }); navigate('recipe-add-production-step', { recipeId });
break; 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 () => { const handleRemoveStep = useCallback(async () => {
if (!recipe || !stepToRemove) return; if (!recipe || !stepToRemove) return;
setMode('menu'); setMode('menu');
@ -246,15 +372,20 @@ export function RecipeDetailScreen() {
<Text color="gray">Ausgabemenge:</Text> <Text color="gray">Ausgabemenge:</Text>
<Text>{recipe.outputQuantity} {recipe.outputUom}</Text> <Text>{recipe.outputQuantity} {recipe.outputUom}</Text>
</Box> </Box>
<Box gap={2}>
<Text color="gray">Artikel:</Text>
<Text>{articleMap[recipe.articleId] ?? recipe.articleId}</Text>
</Box>
{recipe.ingredients.length > 0 && ( {recipe.ingredients.length > 0 && (
<Box flexDirection="column" marginTop={1}> <Box flexDirection="column" marginTop={1}>
<Text color="gray">Zutaten:</Text> <Text color="gray">Zutaten:</Text>
{recipe.ingredients.map((ing) => ( {sortedIngredients.map((ing) => (
<Box key={ing.id} paddingLeft={2} gap={1}> <Box key={ing.id} paddingLeft={2} gap={1}>
<Text color="yellow">{ing.position}.</Text> <Text color="yellow">{ing.position}.</Text>
<Text>{ing.quantity} {ing.uom}</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>} {ing.substitutable && <Text color="green">[austauschbar]</Text>}
</Box> </Box>
))} ))}
@ -276,6 +407,25 @@ export function RecipeDetailScreen() {
)} )}
</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' && ( {mode === 'select-step-to-remove' && (
<Box flexDirection="column"> <Box flexDirection="column">
<Text color="yellow" bold>Schritt zum Entfernen auswählen:</Text> <Text color="yellow" bold>Schritt zum Entfernen auswählen:</Text>
@ -296,7 +446,7 @@ export function RecipeDetailScreen() {
{sortedIngredients.map((ing, index) => ( {sortedIngredients.map((ing, index) => (
<Box key={ing.id}> <Box key={ing.id}>
<Text color={index === selectedIngredientIndex ? 'cyan' : 'white'}> <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> </Text>
</Box> </Box>
))} ))}
@ -306,7 +456,7 @@ export function RecipeDetailScreen() {
{mode === 'confirm-remove-ingredient' && ingredientToRemove && ( {mode === 'confirm-remove-ingredient' && ingredientToRemove && (
<ConfirmDialog <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()} onConfirm={() => void handleRemoveIngredient()}
onCancel={() => { setMode('menu'); setIngredientToRemove(null); }} onCancel={() => { setMode('menu'); setIngredientToRemove(null); }}
/> />
@ -356,3 +506,8 @@ export function RecipeDetailScreen() {
</Box> </Box>
); );
} }
function sortedIngredientsOf(recipe: RecipeDTO | null): IngredientDTO[] {
if (!recipe?.ingredients) return [];
return [...recipe.ingredients].sort((a, b) => a.position - b.position);
}

View file

@ -0,0 +1,113 @@
import { useState, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import type { ArticleDTO } from '@effigenix/api-client';
interface ArticlePickerProps {
articles: ArticleDTO[];
query: string;
onQueryChange: (q: string) => void;
onSelect: (article: ArticleDTO) => void;
focus: boolean;
selectedName?: string;
maxVisible?: number;
}
export function ArticlePicker({
articles,
query,
onQueryChange,
onSelect,
focus,
selectedName,
maxVisible = 5,
}: ArticlePickerProps) {
const [cursor, setCursor] = useState(0);
const filtered = useMemo(() => {
if (!query) return [];
const q = query.toLowerCase();
return articles.filter(
(a) => a.name.toLowerCase().includes(q) || a.articleNumber.toLowerCase().includes(q),
).slice(0, maxVisible);
}, [articles, query, maxVisible]);
useInput((input, key) => {
if (!focus) return;
if (key.upArrow) {
setCursor((c) => Math.max(0, c - 1));
return;
}
if (key.downArrow) {
setCursor((c) => Math.min(filtered.length - 1, c + 1));
return;
}
if (key.return && filtered.length > 0) {
const selected = filtered[cursor];
if (selected) onSelect(selected);
return;
}
if (key.backspace || key.delete) {
onQueryChange(query.slice(0, -1));
setCursor(0);
return;
}
if (key.tab || key.escape || key.ctrl || key.meta) return;
if (input && !key.upArrow && !key.downArrow) {
onQueryChange(query + input);
setCursor(0);
}
}, { isActive: focus });
if (!focus && selectedName) {
return (
<Box flexDirection="column">
<Text color="gray">Artikel *</Text>
<Box>
<Text color="gray"> </Text>
<Text color="green"> {selectedName}</Text>
</Box>
</Box>
);
}
if (focus && selectedName && !query) {
return (
<Box flexDirection="column">
<Text color="cyan">Artikel * (tippen zum Ändern)</Text>
<Box>
<Text color="gray"> </Text>
<Text color="green"> {selectedName}</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column">
<Text color={focus ? 'cyan' : 'gray'}>Artikel * (Suche)</Text>
<Box>
<Text color="gray"> </Text>
<Text>{query || (focus ? '▌' : '')}</Text>
</Box>
{focus && filtered.length > 0 && (
<Box flexDirection="column" paddingLeft={2}>
{filtered.map((a, i) => (
<Box key={a.id}>
<Text color={i === cursor ? 'cyan' : 'white'}>
{i === cursor ? '▶ ' : ' '}{a.articleNumber} {a.name}
</Text>
</Box>
))}
</Box>
)}
{focus && query && filtered.length === 0 && (
<Box paddingLeft={2}>
<Text color="yellow">Keine Artikel gefunden.</Text>
</Box>
)}
</Box>
);
}

View file

@ -41,7 +41,7 @@ export function ChangePasswordScreen() {
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f; return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
}); });
} }
if (key.escape) { if (key.escape || key.backspace) {
back(); back();
} }
}); });

View file

@ -11,7 +11,7 @@ type Field = 'username' | 'email' | 'password' | 'roleName';
const FIELDS: Field[] = ['username', 'email', 'password', 'roleName']; const FIELDS: Field[] = ['username', 'email', 'password', 'roleName'];
export function UserCreateScreen() { export function UserCreateScreen() {
const { navigate, back } = useNavigation(); const { replace, back } = useNavigation();
const { createUser, loading, error, clearError } = useUsers(); const { createUser, loading, error, clearError } = useUsers();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@ -73,7 +73,7 @@ export function UserCreateScreen() {
const user = await createUser(username.trim(), email.trim(), password.trim(), roleName.trim() || undefined); const user = await createUser(username.trim(), email.trim(), password.trim(), roleName.trim() || undefined);
if (user) { if (user) {
navigate('user-list'); replace('user-list');
} }
}; };

View file

@ -51,6 +51,7 @@ interface NavigationState {
type NavigationAction = type NavigationAction =
| { type: 'NAVIGATE'; screen: Screen; params?: Record<string, string> } | { type: 'NAVIGATE'; screen: Screen; params?: Record<string, string> }
| { type: 'REPLACE'; screen: Screen; params?: Record<string, string> }
| { type: 'BACK' }; | { type: 'BACK' };
function navigationReducer(state: NavigationState, action: NavigationAction): NavigationState { function navigationReducer(state: NavigationState, action: NavigationAction): NavigationState {
@ -61,6 +62,12 @@ function navigationReducer(state: NavigationState, action: NavigationAction): Na
history: [...state.history, state.current], history: [...state.history, state.current],
params: action.params ?? {}, params: action.params ?? {},
}; };
case 'REPLACE':
return {
current: action.screen,
history: state.history,
params: action.params ?? {},
};
case 'BACK': { case 'BACK': {
const history = [...state.history]; const history = [...state.history];
const previous = history.pop(); const previous = history.pop();
@ -75,6 +82,7 @@ interface NavigationContextValue {
params: Record<string, string>; params: Record<string, string>;
canGoBack: boolean; canGoBack: boolean;
navigate: (screen: Screen, params?: Record<string, string>) => void; navigate: (screen: Screen, params?: Record<string, string>) => void;
replace: (screen: Screen, params?: Record<string, string>) => void;
back: () => void; back: () => void;
} }
@ -97,6 +105,7 @@ export function NavigationProvider({ children, initialScreen = 'login' }: Naviga
params: state.params, params: state.params,
canGoBack: state.history.length > 0, canGoBack: state.history.length > 0,
navigate: (screen, params) => dispatch({ type: 'NAVIGATE', screen, params: params ?? {} }), navigate: (screen, params) => dispatch({ type: 'NAVIGATE', screen, params: params ?? {} }),
replace: (screen, params) => dispatch({ type: 'REPLACE', screen, params: params ?? {} }),
back: () => dispatch({ type: 'BACK' }), back: () => dispatch({ type: 'BACK' }),
}; };

File diff suppressed because one or more lines are too long

View file

@ -113,8 +113,8 @@ export type {
StorageLocationFilter, StorageLocationFilter,
} from './resources/storage-locations.js'; } from './resources/storage-locations.js';
export { STORAGE_TYPE_LABELS } from './resources/storage-locations.js'; export { STORAGE_TYPE_LABELS } from './resources/storage-locations.js';
export type { RecipesResource, RecipeType, RecipeStatus } from './resources/recipes.js'; export type { RecipesResource, RecipeType, RecipeStatus, UoM } from './resources/recipes.js';
export { RECIPE_TYPE_LABELS } from './resources/recipes.js'; export { RECIPE_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from './resources/recipes.js';
export type { StocksResource, BatchType } from './resources/stocks.js'; export type { StocksResource, BatchType } from './resources/stocks.js';
export { BATCH_TYPE_LABELS } from './resources/stocks.js'; export { BATCH_TYPE_LABELS } from './resources/stocks.js';

View file

@ -20,6 +20,17 @@ export const RECIPE_TYPE_LABELS: Record<RecipeType, string> = {
FINISHED_PRODUCT: 'Fertigprodukt', FINISHED_PRODUCT: 'Fertigprodukt',
}; };
export type UoM = 'KILOGRAM' | 'GRAM' | 'LITER' | 'MILLILITER' | 'PIECE' | 'METER';
export const UOM_VALUES: UoM[] = ['KILOGRAM', 'GRAM', 'LITER', 'MILLILITER', 'PIECE', 'METER'];
export const UOM_LABELS: Record<UoM, string> = {
KILOGRAM: 'Kilogramm (kg)',
GRAM: 'Gramm (g)',
LITER: 'Liter (L)',
MILLILITER: 'Milliliter (mL)',
PIECE: 'Stück (pc)',
METER: 'Meter (m)',
};
export type { export type {
RecipeDTO, RecipeDTO,
RecipeSummaryDTO, RecipeSummaryDTO,

View file

@ -420,6 +420,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/production/batches": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["planBatch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/inventory/storage-locations": { "/api/inventory/storage-locations": {
parameters: { parameters: {
query?: never; query?: never;
@ -468,6 +484,54 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/inventory/stocks/{stockId}/batches/{batchId}/unblock": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["unblockBatch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/inventory/stocks/{stockId}/batches/{batchId}/remove": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["removeBatch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/inventory/stocks/{stockId}/batches/{batchId}/block": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["blockBatch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/customers": { "/api/customers": {
parameters: { parameters: {
query?: never; query?: never;
@ -1171,6 +1235,7 @@ export interface components {
shelfLifeDays?: number; shelfLifeDays?: number;
outputQuantity: string; outputQuantity: string;
outputUom: string; outputUom: string;
articleId: string;
}; };
IngredientResponse: { IngredientResponse: {
id: string; id: string;
@ -1205,6 +1270,7 @@ export interface components {
shelfLifeDays?: number | null; shelfLifeDays?: number | null;
outputQuantity: string; outputQuantity: string;
outputUom: string; outputUom: string;
articleId: string;
status: string; status: string;
ingredients: components["schemas"]["IngredientResponse"][]; ingredients: components["schemas"]["IngredientResponse"][];
productionSteps: components["schemas"]["ProductionStepResponse"][]; productionSteps: components["schemas"]["ProductionStepResponse"][];
@ -1231,6 +1297,31 @@ export interface components {
subRecipeId?: string; subRecipeId?: string;
substitutable?: boolean; substitutable?: boolean;
}; };
PlanBatchRequest: {
recipeId: string;
plannedQuantity: string;
plannedQuantityUnit: string;
/** Format: date */
productionDate: string;
/** Format: date */
bestBeforeDate: string;
};
BatchResponse: {
id?: string;
batchNumber?: string;
recipeId?: string;
status?: string;
plannedQuantity?: string;
plannedQuantityUnit?: string;
/** Format: date */
productionDate?: string;
/** Format: date */
bestBeforeDate?: string;
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
updatedAt?: string;
};
CreateStorageLocationRequest: { CreateStorageLocationRequest: {
name: string; name: string;
storageType: string; storageType: string;
@ -1276,6 +1367,13 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
receivedAt?: string; receivedAt?: string;
}; };
RemoveStockBatchRequest: {
quantityAmount: string;
quantityUnit: string;
};
BlockStockBatchRequest: {
reason: string;
};
CreateCustomerRequest: { CreateCustomerRequest: {
name: string; name: string;
/** @enum {string} */ /** @enum {string} */
@ -1383,6 +1481,7 @@ export interface components {
shelfLifeDays?: number | null; shelfLifeDays?: number | null;
outputQuantity: string; outputQuantity: string;
outputUom: string; outputUom: string;
articleId: string;
status: string; status: string;
/** Format: int32 */ /** Format: int32 */
ingredientCount: number; ingredientCount: number;
@ -2388,6 +2487,30 @@ export interface operations {
}; };
}; };
}; };
planBatch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PlanBatchRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BatchResponse"];
};
};
};
};
listStorageLocations: { listStorageLocations: {
parameters: { parameters: {
query?: { query?: {
@ -2485,6 +2608,77 @@ export interface operations {
}; };
}; };
}; };
unblockBatch: {
parameters: {
query?: never;
header?: never;
path: {
stockId: string;
batchId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
removeBatch: {
parameters: {
query?: never;
header?: never;
path: {
stockId: string;
batchId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RemoveStockBatchRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
blockBatch: {
parameters: {
query?: never;
header?: never;
path: {
stockId: string;
batchId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BlockStockBatchRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listCustomers: { listCustomers: {
parameters: { parameters: {
query?: { query?: {