mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 06:39:34 +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:
parent
b46495e1aa
commit
6c1e6c24bc
48 changed files with 999 additions and 237 deletions
|
|
@ -26,7 +26,7 @@ public class CreateRecipe {
|
|||
var draft = new RecipeDraft(
|
||||
cmd.name(), cmd.version(), cmd.type(), cmd.description(),
|
||||
cmd.yieldPercentage(), cmd.shelfLifeDays(),
|
||||
cmd.outputQuantity(), cmd.outputUom()
|
||||
cmd.outputQuantity(), cmd.outputUom(), cmd.articleId()
|
||||
);
|
||||
|
||||
Recipe recipe;
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ public record CreateRecipeCommand(
|
|||
int yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
String outputQuantity,
|
||||
String outputUom
|
||||
String outputUom,
|
||||
String articleId
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import static de.effigenix.shared.common.Result.*;
|
|||
* 11. Recipe can only be activated when it has at least one ingredient
|
||||
* 12. Recipe can only be activated from DRAFT 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 {
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ public class Recipe {
|
|||
private YieldPercentage yieldPercentage;
|
||||
private Integer shelfLifeDays;
|
||||
private Quantity outputQuantity;
|
||||
private String articleId;
|
||||
private RecipeStatus status;
|
||||
private final List<Ingredient> ingredients;
|
||||
private final List<ProductionStep> productionSteps;
|
||||
|
|
@ -57,6 +59,7 @@ public class Recipe {
|
|||
YieldPercentage yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
Quantity outputQuantity,
|
||||
String articleId,
|
||||
RecipeStatus status,
|
||||
List<Ingredient> ingredients,
|
||||
List<ProductionStep> productionSteps,
|
||||
|
|
@ -71,6 +74,7 @@ public class Recipe {
|
|||
this.yieldPercentage = yieldPercentage;
|
||||
this.shelfLifeDays = shelfLifeDays;
|
||||
this.outputQuantity = outputQuantity;
|
||||
this.articleId = articleId;
|
||||
this.status = status;
|
||||
this.ingredients = new ArrayList<>(ingredients);
|
||||
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;
|
||||
try {
|
||||
BigDecimal amount = new BigDecimal(draft.outputQuantity());
|
||||
|
|
@ -128,7 +137,7 @@ public class Recipe {
|
|||
return Result.success(new Recipe(
|
||||
RecipeId.generate(), name, draft.version(), draft.type(),
|
||||
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,
|
||||
Integer shelfLifeDays,
|
||||
Quantity outputQuantity,
|
||||
String articleId,
|
||||
RecipeStatus status,
|
||||
List<Ingredient> ingredients,
|
||||
List<ProductionStep> productionSteps,
|
||||
|
|
@ -151,7 +161,7 @@ public class Recipe {
|
|||
OffsetDateTime updatedAt
|
||||
) {
|
||||
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 ====================
|
||||
|
|
@ -253,6 +263,7 @@ public class Recipe {
|
|||
public YieldPercentage yieldPercentage() { return yieldPercentage; }
|
||||
public Integer shelfLifeDays() { return shelfLifeDays; }
|
||||
public Quantity outputQuantity() { return outputQuantity; }
|
||||
public String articleId() { return articleId; }
|
||||
public RecipeStatus status() { return status; }
|
||||
public List<Ingredient> ingredients() { return Collections.unmodifiableList(ingredients); }
|
||||
public List<ProductionStep> productionSteps() { return Collections.unmodifiableList(productionSteps); }
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ package de.effigenix.domain.production;
|
|||
* @param shelfLifeDays Shelf life in days (nullable; required for FINISHED_PRODUCT and INTERMEDIATE)
|
||||
* @param outputQuantity Expected output quantity amount (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(
|
||||
String name,
|
||||
|
|
@ -21,5 +22,6 @@ public record RecipeDraft(
|
|||
int yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
String outputQuantity,
|
||||
String outputUom
|
||||
String outputUom,
|
||||
String articleId
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ public class RecipeEntity {
|
|||
@Column(name = "output_uom", nullable = false, length = 20)
|
||||
private String outputUom;
|
||||
|
||||
@Column(name = "article_id", nullable = false, length = 36)
|
||||
private String articleId;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private String status;
|
||||
|
||||
|
|
@ -61,7 +64,7 @@ public class RecipeEntity {
|
|||
|
||||
public RecipeEntity(String id, String name, int version, String type, String description,
|
||||
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.name = name;
|
||||
this.version = version;
|
||||
|
|
@ -71,6 +74,7 @@ public class RecipeEntity {
|
|||
this.shelfLifeDays = shelfLifeDays;
|
||||
this.outputQuantity = outputQuantity;
|
||||
this.outputUom = outputUom;
|
||||
this.articleId = articleId;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
|
|
@ -85,6 +89,7 @@ public class RecipeEntity {
|
|||
public Integer getShelfLifeDays() { return shelfLifeDays; }
|
||||
public BigDecimal getOutputQuantity() { return outputQuantity; }
|
||||
public String getOutputUom() { return outputUom; }
|
||||
public String getArticleId() { return articleId; }
|
||||
public String getStatus() { return status; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
|
|
@ -100,6 +105,7 @@ public class RecipeEntity {
|
|||
public void setShelfLifeDays(Integer shelfLifeDays) { this.shelfLifeDays = shelfLifeDays; }
|
||||
public void setOutputQuantity(BigDecimal outputQuantity) { this.outputQuantity = outputQuantity; }
|
||||
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 setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public class RecipeMapper {
|
|||
recipe.shelfLifeDays(),
|
||||
recipe.outputQuantity().amount(),
|
||||
recipe.outputQuantity().uom().name(),
|
||||
recipe.articleId(),
|
||||
recipe.status().name(),
|
||||
recipe.createdAt(),
|
||||
recipe.updatedAt()
|
||||
|
|
@ -64,6 +65,7 @@ public class RecipeMapper {
|
|||
entity.getOutputQuantity(),
|
||||
UnitOfMeasure.valueOf(entity.getOutputUom())
|
||||
),
|
||||
entity.getArticleId(),
|
||||
RecipeStatus.valueOf(entity.getStatus()),
|
||||
ingredients,
|
||||
productionSteps,
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ public class RecipeController {
|
|||
var cmd = new CreateRecipeCommand(
|
||||
request.name(), request.version(), request.type(), request.description(),
|
||||
request.yieldPercentage(), request.shelfLifeDays(),
|
||||
request.outputQuantity(), request.outputUom()
|
||||
request.outputQuantity(), request.outputUom(), request.articleId()
|
||||
);
|
||||
var result = createRecipe.execute(cmd, actorId);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,5 +12,6 @@ public record CreateRecipeRequest(
|
|||
int yieldPercentage,
|
||||
Integer shelfLifeDays,
|
||||
@NotBlank String outputQuantity,
|
||||
@NotBlank String outputUom
|
||||
@NotBlank String outputUom,
|
||||
@NotBlank String articleId
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||
import java.time.OffsetDateTime;
|
||||
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(
|
||||
String id,
|
||||
String name,
|
||||
|
|
@ -17,6 +17,7 @@ public record RecipeResponse(
|
|||
@Schema(nullable = true) Integer shelfLifeDays,
|
||||
String outputQuantity,
|
||||
String outputUom,
|
||||
String articleId,
|
||||
String status,
|
||||
List<IngredientResponse> ingredients,
|
||||
List<ProductionStepResponse> productionSteps,
|
||||
|
|
@ -34,6 +35,7 @@ public record RecipeResponse(
|
|||
recipe.shelfLifeDays(),
|
||||
recipe.outputQuantity().amount().toPlainString(),
|
||||
recipe.outputQuantity().uom().name(),
|
||||
recipe.articleId(),
|
||||
recipe.status().name(),
|
||||
recipe.ingredients().stream().map(IngredientResponse::from).toList(),
|
||||
recipe.productionSteps().stream().map(ProductionStepResponse::from).toList(),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||
|
||||
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(
|
||||
String id,
|
||||
String name,
|
||||
|
|
@ -16,6 +16,7 @@ public record RecipeSummaryResponse(
|
|||
@Schema(nullable = true) Integer shelfLifeDays,
|
||||
String outputQuantity,
|
||||
String outputUom,
|
||||
String articleId,
|
||||
String status,
|
||||
int ingredientCount,
|
||||
int stepCount,
|
||||
|
|
@ -33,6 +34,7 @@ public record RecipeSummaryResponse(
|
|||
recipe.shelfLifeDays(),
|
||||
recipe.outputQuantity().amount().toPlainString(),
|
||||
recipe.outputQuantity().uom().name(),
|
||||
recipe.articleId(),
|
||||
recipe.status().name(),
|
||||
recipe.ingredients().size(),
|
||||
recipe.productionSteps().size(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -22,5 +22,6 @@
|
|||
<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/017-timestamps-to-timestamptz.xml"/>
|
||||
<include file="db/changelog/changes/018-add-article-id-to-recipes.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class ActivateRecipeTest {
|
|||
private Recipe draftRecipeWithIngredient() {
|
||||
var recipe = Recipe.create(new RecipeDraft(
|
||||
"Bratwurst", 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, 85, 14, "100", "KILOGRAM"
|
||||
null, 85, 14, "100", "KILOGRAM", "article-123"
|
||||
)).unsafeGetValue();
|
||||
recipe.addIngredient(new IngredientDraft(1, "article-123", "5.5", "KILOGRAM", null, false));
|
||||
return recipe;
|
||||
|
|
@ -52,7 +52,7 @@ class ActivateRecipeTest {
|
|||
private Recipe draftRecipeWithoutIngredient() {
|
||||
return Recipe.create(new RecipeDraft(
|
||||
"Bratwurst", 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, 85, 14, "100", "KILOGRAM"
|
||||
null, 85, 14, "100", "KILOGRAM", "article-123"
|
||||
)).unsafeGetValue();
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ class ActivateRecipeTest {
|
|||
RecipeId.of("recipe-1"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class AddRecipeIngredientTest {
|
|||
private Recipe draftRecipe() {
|
||||
return Recipe.create(new RecipeDraft(
|
||||
"Bratwurst", 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, 85, 14, "100", "KILOGRAM"
|
||||
null, 85, 14, "100", "KILOGRAM", "article-123"
|
||||
)).unsafeGetValue();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class ArchiveRecipeTest {
|
|||
RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ class ArchiveRecipeTest {
|
|||
private Recipe draftRecipe() {
|
||||
return Recipe.create(new RecipeDraft(
|
||||
"Bratwurst", 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, 85, 14, "100", "KILOGRAM"
|
||||
null, 85, 14, "100", "KILOGRAM", "article-123"
|
||||
)).unsafeGetValue();
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ class ArchiveRecipeTest {
|
|||
RecipeId.of("recipe-2"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class GetRecipeTest {
|
|||
RecipeId.of(id), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
"Beschreibung", new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class ListRecipesTest {
|
|||
RecipeId.of(id), new RecipeName("Rezept-" + id), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class PlanBatchTest {
|
|||
RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ class PlanBatchTest {
|
|||
RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class RecipeCycleCheckerTest {
|
|||
RecipeId.of(id), new RecipeName("Recipe-" + id), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(100), 14,
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ class RecipeCycleCheckerTest {
|
|||
RecipeId.of("B"), new RecipeName("Recipe-B"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(100), 14,
|
||||
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));
|
||||
|
||||
when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeB)));
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class RecipeTest {
|
|||
return new RecipeDraft(
|
||||
"Bratwurst Grob", 1, RecipeType.FINISHED_PRODUCT,
|
||||
"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")
|
||||
void should_Fail_When_NameIsBlank() {
|
||||
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);
|
||||
|
|
@ -69,7 +69,7 @@ class RecipeTest {
|
|||
@DisplayName("should fail when name is null")
|
||||
void should_Fail_When_NameIsNull() {
|
||||
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);
|
||||
|
|
@ -82,7 +82,7 @@ class RecipeTest {
|
|||
@DisplayName("should fail when version is less than 1")
|
||||
void should_Fail_When_VersionLessThanOne() {
|
||||
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);
|
||||
|
|
@ -95,7 +95,7 @@ class RecipeTest {
|
|||
@DisplayName("should fail when yield percentage is 0")
|
||||
void should_Fail_When_YieldPercentageIsZero() {
|
||||
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);
|
||||
|
|
@ -108,7 +108,7 @@ class RecipeTest {
|
|||
@DisplayName("should fail when yield percentage is 201")
|
||||
void should_Fail_When_YieldPercentageIs201() {
|
||||
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);
|
||||
|
|
@ -121,7 +121,7 @@ class RecipeTest {
|
|||
@DisplayName("should fail when shelf life is 0 for FINISHED_PRODUCT")
|
||||
void should_Fail_When_ShelfLifeZeroForFinishedProduct() {
|
||||
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);
|
||||
|
|
@ -134,7 +134,7 @@ class RecipeTest {
|
|||
@DisplayName("should fail when shelf life is null for FINISHED_PRODUCT")
|
||||
void should_Fail_When_ShelfLifeNullForFinishedProduct() {
|
||||
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);
|
||||
|
|
@ -147,7 +147,7 @@ class RecipeTest {
|
|||
@DisplayName("should fail when shelf life is 0 for INTERMEDIATE")
|
||||
void should_Fail_When_ShelfLifeZeroForIntermediate() {
|
||||
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);
|
||||
|
|
@ -160,7 +160,7 @@ class RecipeTest {
|
|||
@DisplayName("should allow null shelf life for RAW_MATERIAL")
|
||||
void should_AllowNullShelfLife_When_RawMaterial() {
|
||||
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);
|
||||
|
|
@ -173,7 +173,7 @@ class RecipeTest {
|
|||
@DisplayName("should fail when output quantity is zero")
|
||||
void should_Fail_When_OutputQuantityZero() {
|
||||
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);
|
||||
|
|
@ -186,7 +186,7 @@ class RecipeTest {
|
|||
@DisplayName("should fail when output UoM is invalid")
|
||||
void should_Fail_When_OutputUomInvalid() {
|
||||
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);
|
||||
|
|
@ -199,7 +199,7 @@ class RecipeTest {
|
|||
@DisplayName("should allow description to be null")
|
||||
void should_AllowNullDescription() {
|
||||
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);
|
||||
|
|
@ -238,7 +238,7 @@ class RecipeTest {
|
|||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
|
||||
|
|
@ -321,7 +321,7 @@ class RecipeTest {
|
|||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
|
||||
|
|
@ -375,7 +375,7 @@ class RecipeTest {
|
|||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
|
||||
|
|
@ -444,7 +444,7 @@ class RecipeTest {
|
|||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
|
||||
|
|
@ -506,7 +506,7 @@ class RecipeTest {
|
|||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
|
||||
|
|
@ -527,7 +527,7 @@ class RecipeTest {
|
|||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
|
||||
|
|
@ -553,7 +553,7 @@ class RecipeTest {
|
|||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
var updatedBefore = recipe.updatedAt();
|
||||
|
|
@ -587,7 +587,7 @@ class RecipeTest {
|
|||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||
null, new YieldPercentage(85), 14,
|
||||
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)
|
||||
);
|
||||
|
||||
|
|
@ -614,7 +614,7 @@ class RecipeTest {
|
|||
recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL,
|
||||
null, new YieldPercentage(100), null,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -312,7 +312,8 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest {
|
|||
"yieldPercentage": 85,
|
||||
"shelfLifeDays": 14,
|
||||
"outputQuantity": "100",
|
||||
"outputUom": "KILOGRAM"
|
||||
"outputUom": "KILOGRAM",
|
||||
"articleId": "article-123"
|
||||
}
|
||||
""".formatted(UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,8 @@ class GetRecipeIntegrationTest extends AbstractIntegrationTest {
|
|||
"yieldPercentage": 85,
|
||||
"shelfLifeDays": 14,
|
||||
"outputQuantity": "100",
|
||||
"outputUom": "KILOGRAM"
|
||||
"outputUom": "KILOGRAM",
|
||||
"articleId": "article-123"
|
||||
}
|
||||
""";
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,8 @@ class ListRecipesIntegrationTest extends AbstractIntegrationTest {
|
|||
"yieldPercentage": 85,
|
||||
"shelfLifeDays": 14,
|
||||
"outputQuantity": "100",
|
||||
"outputUom": "KILOGRAM"
|
||||
"outputUom": "KILOGRAM",
|
||||
"articleId": "article-123"
|
||||
}
|
||||
""".formatted(name, version);
|
||||
|
||||
|
|
|
|||
53
docs/tickets/recipe-reorder-backend.md
Normal file
53
docs/tickets/recipe-reorder-backend.md
Normal 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: [...] });
|
||||
```
|
||||
|
|
@ -6,8 +6,8 @@ import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
|||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
import { SuccessDisplay } from '../shared/SuccessDisplay.js';
|
||||
import { useStocks } from '../../hooks/useStocks.js';
|
||||
import { BATCH_TYPE_LABELS } from '@effigenix/api-client';
|
||||
import type { BatchType } from '@effigenix/api-client';
|
||||
import { BATCH_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from '@effigenix/api-client';
|
||||
import type { BatchType, UoM } from '@effigenix/api-client';
|
||||
|
||||
type 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. *',
|
||||
batchType: 'Chargentyp *',
|
||||
quantityAmount: 'Menge *',
|
||||
quantityUnit: 'Einheit *',
|
||||
quantityUnit: 'Einheit * (←→ wechseln)',
|
||||
expiryDate: 'Ablaufdatum (YYYY-MM-DD) *',
|
||||
};
|
||||
|
||||
const BATCH_TYPES: BatchType[] = ['PURCHASED', 'PRODUCED'];
|
||||
|
||||
export function AddBatchScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const { params, replace, back } = useNavigation();
|
||||
const stockId = params['stockId'] ?? '';
|
||||
|
||||
const { addBatch, loading, error, clearError } = useStocks();
|
||||
|
||||
const [values, setValues] = useState<Record<Field, string>>({
|
||||
batchId: '', batchType: 'PURCHASED', quantityAmount: '', quantityUnit: '', expiryDate: '',
|
||||
const [values, setValues] = useState<Record<Exclude<Field, 'quantityUnit'>, string>>({
|
||||
batchId: '', batchType: 'PURCHASED', quantityAmount: '', expiryDate: '',
|
||||
});
|
||||
const [uomIdx, setUomIdx] = useState(0);
|
||||
const [activeField, setActiveField] = useState<Field>('batchId');
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
||||
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 }));
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (loading) return;
|
||||
|
||||
|
|
@ -51,6 +87,22 @@ export function AddBatchScreen() {
|
|||
if (next) setValues((v) => ({ ...v, batchType: next }));
|
||||
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) {
|
||||
|
|
@ -68,49 +120,16 @@ export function AddBatchScreen() {
|
|||
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 (loading) return <Box paddingY={2}><LoadingSpinner label="Charge wird eingebucht..." /></Box>;
|
||||
|
||||
const uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Charge einbuchen</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
{success && <SuccessDisplay message={success} onDismiss={() => navigate('inventory-menu')} />}
|
||||
{success && <SuccessDisplay message={success} onDismiss={() => replace('inventory-menu')} />}
|
||||
|
||||
{!success && (
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
|
|
@ -120,7 +139,16 @@ export function AddBatchScreen() {
|
|||
return (
|
||||
<Box key={field} flexDirection="column">
|
||||
<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>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -129,8 +157,8 @@ export function AddBatchScreen() {
|
|||
<FormInput
|
||||
key={field}
|
||||
label={FIELD_LABELS[field]}
|
||||
value={values[field]}
|
||||
onChange={setField(field)}
|
||||
value={values[field as Exclude<Field, 'quantityUnit'>]}
|
||||
onChange={setField(field as Exclude<Field, 'quantityUnit'>)}
|
||||
onSubmit={handleFieldSubmit(field)}
|
||||
focus={activeField === field}
|
||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
||||
|
|
@ -142,7 +170,7 @@ export function AddBatchScreen() {
|
|||
|
||||
<Box marginTop={1}>
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function StockBatchEntryScreen() {
|
|||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.escape) back();
|
||||
if (key.escape || key.backspace) back();
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const FIELD_LABELS: Record<Field, string> = {
|
|||
const STORAGE_TYPES: StorageType[] = ['COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA'];
|
||||
|
||||
export function StorageLocationCreateScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { replace, back } = useNavigation();
|
||||
const { createStorageLocation, loading, error, clearError } = useStorageLocations();
|
||||
|
||||
const [values, setValues] = useState<Record<Field, string>>({
|
||||
|
|
@ -78,7 +78,7 @@ export function StorageLocationCreateScreen() {
|
|||
...(values.minTemperature.trim() ? { minTemperature: values.minTemperature.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) => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const UNIT_PRICE_MODEL: Record<Unit, PriceModel> = {
|
|||
};
|
||||
|
||||
export function AddSalesUnitScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const { params, replace, back } = useNavigation();
|
||||
const articleId = params['articleId'] ?? '';
|
||||
const { addSalesUnit, loading, error, clearError } = useArticles();
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ export function AddSalesUnitScreen() {
|
|||
}
|
||||
setPriceError(null);
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const UNIT_PRICE_MODEL: Record<Unit, PriceModel> = {
|
|||
};
|
||||
|
||||
export function ArticleCreateScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { replace, back } = useNavigation();
|
||||
const { createArticle, loading, error, clearError } = useArticles();
|
||||
const { categories, fetchCategories } = useCategories();
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ export function ArticleCreateScreen() {
|
|||
priceModel: autoModel,
|
||||
price: priceNum,
|
||||
});
|
||||
if (result) navigate('article-list');
|
||||
if (result) replace('article-list');
|
||||
};
|
||||
|
||||
if (loading) return <Box paddingY={2}><LoadingSpinner label="Artikel wird angelegt..." /></Box>;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ type Field = 'name' | 'description';
|
|||
const FIELDS: Field[] = ['name', 'description'];
|
||||
|
||||
export function CategoryCreateScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { replace, back } = useNavigation();
|
||||
const { createCategory, loading, error, clearError } = useCategories();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
|
|
@ -42,7 +42,7 @@ export function CategoryCreateScreen() {
|
|||
return;
|
||||
}
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const FIELD_LABELS: Record<Field, string> = {
|
|||
};
|
||||
|
||||
export function AddDeliveryAddressScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const { params, replace, back } = useNavigation();
|
||||
const customerId = params['customerId'] ?? '';
|
||||
const { addDeliveryAddress, loading, error, clearError } = useCustomers();
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ export function AddDeliveryAddressScreen() {
|
|||
...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.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) => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ const FIELD_LABELS: Record<Field, string> = {
|
|||
const TYPES: CustomerType[] = ['B2B', 'B2C'];
|
||||
|
||||
export function CustomerCreateScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { replace, back } = useNavigation();
|
||||
const { createCustomer, loading, error, clearError } = useCustomers();
|
||||
|
||||
const [values, setValues] = useState<Record<Field, string>>({
|
||||
|
|
@ -91,7 +91,7 @@ export function CustomerCreateScreen() {
|
|||
...(values.email.trim() ? { email: values.email.trim() } : {}),
|
||||
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
|
||||
});
|
||||
if (result) navigate('customer-list');
|
||||
if (result) replace('customer-list');
|
||||
};
|
||||
|
||||
const handleFieldSubmit = (field: Field) => (_value: string) => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ function errorMessage(err: unknown): string {
|
|||
}
|
||||
|
||||
export function SetPreferencesScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const { params, replace, back } = useNavigation();
|
||||
const customerId = params['customerId'] ?? '';
|
||||
const { setPreferences, loading, error, clearError } = useCustomers();
|
||||
|
||||
|
|
@ -53,8 +53,8 @@ export function SetPreferencesScreen() {
|
|||
|
||||
const handleSave = useCallback(async () => {
|
||||
const updated = await setPreferences(customerId, Array.from(checked));
|
||||
if (updated) navigate('customer-detail', { customerId });
|
||||
}, [customerId, checked, setPreferences, navigate]);
|
||||
if (updated) replace('customer-detail', { customerId });
|
||||
}, [customerId, checked, setPreferences, replace]);
|
||||
|
||||
if (initLoading) return <LoadingSpinner label="Lade Präferenzen..." />;
|
||||
if (initError) return <ErrorDisplay message={initError} onDismiss={back} />;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ function isValidDate(s: string): boolean {
|
|||
}
|
||||
|
||||
export function AddCertificateScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const { params, replace, back } = useNavigation();
|
||||
const supplierId = params['supplierId'] ?? '';
|
||||
const { addCertificate, loading, error, clearError } = useSuppliers();
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ export function AddCertificateScreen() {
|
|||
validFrom: values.validFrom,
|
||||
validUntil: values.validUntil,
|
||||
});
|
||||
if (updated) navigate('supplier-detail', { supplierId });
|
||||
if (updated) replace('supplier-detail', { supplierId });
|
||||
};
|
||||
|
||||
const handleFieldSubmit = (field: Field) => (_value: string) => {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ function ScoreSelector({ label, value, active }: { label: string; value: number;
|
|||
}
|
||||
|
||||
export function RateSupplierScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const { params, replace, back } = useNavigation();
|
||||
const supplierId = params['supplierId'] ?? '';
|
||||
const { rateSupplier, loading, error, clearError } = useSuppliers();
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ export function RateSupplierScreen() {
|
|||
});
|
||||
if (updated) {
|
||||
setSuccessMessage('Bewertung gespeichert.');
|
||||
setTimeout(() => navigate('supplier-detail', { supplierId }), 1000);
|
||||
setTimeout(() => replace('supplier-detail', { supplierId }), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const FIELD_LABELS: Record<Field, string> = {
|
|||
};
|
||||
|
||||
export function SupplierCreateScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { replace, back } = useNavigation();
|
||||
const { createSupplier, loading, error, clearError } = useSuppliers();
|
||||
|
||||
const [values, setValues] = useState<Record<Field, string>>({
|
||||
|
|
@ -74,7 +74,7 @@ export function SupplierCreateScreen() {
|
|||
...(values.country.trim() ? { country: values.country.trim() } : {}),
|
||||
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
|
||||
});
|
||||
if (result) navigate('supplier-list');
|
||||
if (result) replace('supplier-list');
|
||||
};
|
||||
|
||||
const handleFieldSubmit = (field: Field) => (_value: string) => {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { FormInput } from '../shared/FormInput.js';
|
||||
import { ArticlePicker } from '../shared/ArticlePicker.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.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';
|
||||
|
||||
type Field = 'position' | 'articleId' | 'quantity' | 'uom' | 'subRecipeId' | 'substitutable';
|
||||
const FIELDS: Field[] = ['position', 'articleId', 'quantity', 'uom', 'subRecipeId', 'substitutable'];
|
||||
type Field = 'articleId' | 'quantity' | 'uom' | 'subRecipeId' | 'substitutable';
|
||||
const FIELDS: Field[] = ['articleId', 'quantity', 'uom', 'subRecipeId', 'substitutable'];
|
||||
|
||||
const FIELD_LABELS: Record<Field, string> = {
|
||||
position: 'Position (optional)',
|
||||
articleId: 'Artikel-ID *',
|
||||
articleId: 'Artikel *',
|
||||
quantity: 'Menge *',
|
||||
uom: 'Einheit *',
|
||||
uom: 'Einheit * (←→ wechseln)',
|
||||
subRecipeId: 'Sub-Rezept-ID (optional)',
|
||||
substitutable: 'Austauschbar (ja/nein, optional)',
|
||||
};
|
||||
|
|
@ -23,65 +26,81 @@ function errorMessage(err: unknown): string {
|
|||
}
|
||||
|
||||
export function AddIngredientScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const { params, replace, back } = useNavigation();
|
||||
const recipeId = params['recipeId'] ?? '';
|
||||
|
||||
const [values, setValues] = useState<Record<Field, string>>({
|
||||
position: '', articleId: '', quantity: '', uom: '', subRecipeId: '', substitutable: '',
|
||||
const { articles, fetchArticles } = useArticles();
|
||||
|
||||
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 [loading, setLoading] = useState(false);
|
||||
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 }));
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
if (key.tab || key.downArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx + 1) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.escape) back();
|
||||
});
|
||||
const handleArticleSelect = useCallback((article: ArticleDTO) => {
|
||||
setSelectedArticle({ id: article.id, name: article.name, articleNumber: article.articleNumber });
|
||||
setArticleQuery('');
|
||||
setActiveField('quantity');
|
||||
|
||||
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 (linked) {
|
||||
setValues((v) => ({ ...v, subRecipeId: linked.id }));
|
||||
}
|
||||
}).catch(() => { /* Rezept-Lookup fehlgeschlagen – subRecipeId bleibt leer */ });
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
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() && 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())) {
|
||||
errors.substitutable = 'Muss "ja" oder "nein" sein.';
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
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);
|
||||
setError(null);
|
||||
try {
|
||||
await client.recipes.addIngredient(recipeId, {
|
||||
articleId: values.articleId.trim(),
|
||||
articleId: selectedArticle!.id,
|
||||
quantity: values.quantity.trim(),
|
||||
uom: values.uom.trim(),
|
||||
...(values.position.trim() ? { position: Number(values.position) } : {}),
|
||||
uom: selectedUom,
|
||||
position: maxPosition + 1,
|
||||
...(values.subRecipeId.trim() ? { subRecipeId: values.subRecipeId.trim() } : {}),
|
||||
...(values.substitutable.trim() ? { substitutable: values.substitutable.trim().toLowerCase() === 'ja' } : {}),
|
||||
});
|
||||
navigate('recipe-detail', { recipeId });
|
||||
replace('recipe-detail', { recipeId });
|
||||
} catch (err: unknown) {
|
||||
setError(errorMessage(err));
|
||||
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 (recipeLoading) return <Box paddingY={2}><LoadingSpinner label="Lade Rezept..." /></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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Zutat hinzufügen</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
{FIELDS.map((field) => (
|
||||
<FormInput
|
||||
key={field}
|
||||
label={FIELD_LABELS[field]}
|
||||
value={values[field]}
|
||||
onChange={setField(field)}
|
||||
onSubmit={handleFieldSubmit(field)}
|
||||
focus={activeField === field}
|
||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
||||
/>
|
||||
))}
|
||||
{FIELDS.map((field) => {
|
||||
if (field === 'articleId') {
|
||||
return (
|
||||
<Box key={field} flexDirection="column">
|
||||
<ArticlePicker
|
||||
articles={articles}
|
||||
query={articleQuery}
|
||||
onQueryChange={setArticleQuery}
|
||||
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 marginTop={1}>
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ function errorMessage(err: unknown): string {
|
|||
}
|
||||
|
||||
export function AddProductionStepScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const { params, replace, back } = useNavigation();
|
||||
const recipeId = params['recipeId'] ?? '';
|
||||
|
||||
const [values, setValues] = useState<Record<Field, string>>({
|
||||
|
|
@ -77,7 +77,7 @@ export function AddProductionStepScreen() {
|
|||
...(values.durationMinutes.trim() ? { durationMinutes: Number(values.durationMinutes) } : {}),
|
||||
...(values.temperatureCelsius.trim() ? { temperatureCelsius: Number(values.temperatureCelsius) } : {}),
|
||||
});
|
||||
navigate('recipe-detail', { recipeId });
|
||||
replace('recipe-detail', { recipeId });
|
||||
} catch (err: unknown) {
|
||||
setError(errorMessage(err));
|
||||
setLoading(false);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { useRecipes } from '../../hooks/useRecipes.js';
|
||||
import { useArticles } from '../../hooks/useArticles.js';
|
||||
import { FormInput } from '../shared/FormInput.js';
|
||||
import { ArticlePicker } from '../shared/ArticlePicker.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
import { RECIPE_TYPE_LABELS } from '@effigenix/api-client';
|
||||
import type { RecipeType } from '@effigenix/api-client';
|
||||
import { RECIPE_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from '@effigenix/api-client';
|
||||
import type { RecipeType, UoM, ArticleDTO } from '@effigenix/api-client';
|
||||
|
||||
type Field = 'name' | 'version' | 'type' | 'description' | 'yieldPercentage' | 'shelfLifeDays' | 'outputQuantity' | 'outputUom';
|
||||
const FIELDS: 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', 'articleId'];
|
||||
|
||||
const FIELD_LABELS: Record<Field, string> = {
|
||||
name: 'Name *',
|
||||
|
|
@ -19,16 +21,20 @@ const FIELD_LABELS: Record<Field, string> = {
|
|||
yieldPercentage: 'Ausbeute (%) *',
|
||||
shelfLifeDays: 'Haltbarkeit (Tage)',
|
||||
outputQuantity: 'Ausgabemenge *',
|
||||
outputUom: 'Mengeneinheit *',
|
||||
outputUom: 'Mengeneinheit * (←→ wechseln)',
|
||||
articleId: 'Artikel *',
|
||||
};
|
||||
|
||||
const RECIPE_TYPES: RecipeType[] = ['RAW_MATERIAL', 'INTERMEDIATE', 'FINISHED_PRODUCT'];
|
||||
|
||||
export function RecipeCreateScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { createRecipe, loading, error, clearError } = useRecipes();
|
||||
type TextFields = Exclude<Field, 'outputUom' | 'articleId'>;
|
||||
|
||||
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: '',
|
||||
version: '1',
|
||||
type: 'FINISHED_PRODUCT',
|
||||
|
|
@ -36,17 +42,68 @@ export function RecipeCreateScreen() {
|
|||
yieldPercentage: '100',
|
||||
shelfLifeDays: '',
|
||||
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 [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 }));
|
||||
};
|
||||
|
||||
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 (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 (key.leftArrow || key.rightArrow) {
|
||||
|
|
@ -56,6 +113,22 @@ export function RecipeCreateScreen() {
|
|||
if (next) setValues((v) => ({ ...v, type: next }));
|
||||
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) {
|
||||
|
|
@ -73,39 +146,6 @@ export function RecipeCreateScreen() {
|
|||
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) {
|
||||
return (
|
||||
<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 uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
|
|
@ -127,18 +168,43 @@ export function RecipeCreateScreen() {
|
|||
return (
|
||||
<Box key={field} flexDirection="column">
|
||||
<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>
|
||||
{fieldErrors[field] && <Text color="red">{fieldErrors[field]}</Text>}
|
||||
</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 (
|
||||
<FormInput
|
||||
key={field}
|
||||
label={FIELD_LABELS[field]}
|
||||
value={values[field]}
|
||||
onChange={setField(field)}
|
||||
value={values[tf]}
|
||||
onChange={setField(tf)}
|
||||
onSubmit={handleFieldSubmit(field)}
|
||||
focus={activeField === field}
|
||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
||||
|
|
@ -149,7 +215,7 @@ export function RecipeCreateScreen() {
|
|||
|
||||
<Box marginTop={1}>
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { RecipeDTO, RecipeType, ProductionStepDTO, IngredientDTO } from '@effigenix/api-client';
|
||||
import type { RecipeDTO, RecipeType, RecipeSummaryDTO, ProductionStepDTO, IngredientDTO, ArticleDTO } from '@effigenix/api-client';
|
||||
import { RECIPE_TYPE_LABELS } from '@effigenix/api-client';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
|
|
@ -9,8 +9,8 @@ import { SuccessDisplay } from '../shared/SuccessDisplay.js';
|
|||
import { ConfirmDialog } from '../shared/ConfirmDialog.js';
|
||||
import { client } from '../../utils/api-client.js';
|
||||
|
||||
type MenuAction = 'add-ingredient' | 'remove-ingredient' | 'add-step' | 'remove-step' | 'activate' | 'archive' | 'back';
|
||||
type Mode = 'menu' | 'select-step-to-remove' | 'confirm-remove' | 'select-ingredient-to-remove' | 'confirm-remove-ingredient' | 'confirm-activate' | 'confirm-archive';
|
||||
type MenuAction = 'add-ingredient' | 'remove-ingredient' | 'reorder-ingredients' | 'add-step' | 'remove-step' | 'activate' | 'archive' | 'back';
|
||||
type Mode = 'menu' | 'select-step-to-remove' | 'confirm-remove' | 'select-ingredient-to-remove' | 'confirm-remove-ingredient' | 'confirm-activate' | 'confirm-archive' | 'reorder-ingredient';
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
|
|
@ -31,6 +31,13 @@ export function RecipeDetailScreen() {
|
|||
const [stepToRemove, setStepToRemove] = useState<ProductionStepDTO | null>(null);
|
||||
const [selectedIngredientIndex, setSelectedIngredientIndex] = useState(0);
|
||||
const [ingredientToRemove, setIngredientToRemove] = useState<IngredientDTO | null>(null);
|
||||
const [articleMap, setArticleMap] = useState<Record<string, string>>({});
|
||||
const [recipeMap, setRecipeMap] = useState<Record<string, string>>({});
|
||||
|
||||
// Reorder state
|
||||
const [reorderList, setReorderList] = useState<IngredientDTO[]>([]);
|
||||
const [reorderCursor, setReorderCursor] = useState(0);
|
||||
const [reorderHeld, setReorderHeld] = useState<number | null>(null);
|
||||
|
||||
const isDraft = recipe?.status === 'DRAFT';
|
||||
const isActive = recipe?.status === 'ACTIVE';
|
||||
|
|
@ -39,6 +46,9 @@ export function RecipeDetailScreen() {
|
|||
...(isDraft ? [
|
||||
{ id: 'add-ingredient' as const, label: '[Zutat hinzufügen]' },
|
||||
{ id: 'remove-ingredient' as const, label: '[Zutat entfernen]' },
|
||||
...(sortedIngredientsOf(recipe).length >= 2
|
||||
? [{ id: 'reorder-ingredients' as const, label: '[Zutaten neu anordnen]' }]
|
||||
: []),
|
||||
{ id: 'add-step' as const, label: '[Schritt hinzufügen]' },
|
||||
{ id: 'remove-step' as const, label: '[Schritt entfernen]' },
|
||||
{ id: 'activate' as const, label: '[Rezept aktivieren]' },
|
||||
|
|
@ -55,16 +65,25 @@ export function RecipeDetailScreen() {
|
|||
? [...recipe.productionSteps].sort((a, b) => a.stepNumber - b.stepNumber)
|
||||
: [];
|
||||
|
||||
const sortedIngredients = recipe?.ingredients
|
||||
? [...recipe.ingredients].sort((a, b) => a.position - b.position)
|
||||
: [];
|
||||
const sortedIngredients = sortedIngredientsOf(recipe);
|
||||
|
||||
const loadRecipe = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
client.recipes.getById(recipeId)
|
||||
.then((r) => { setRecipe(r); setLoading(false); })
|
||||
.catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
|
||||
Promise.all([
|
||||
client.recipes.getById(recipeId),
|
||||
client.articles.list().catch(() => [] as ArticleDTO[]),
|
||||
client.recipes.list().catch(() => [] as RecipeSummaryDTO[]),
|
||||
]).then(([r, articles, recipes]) => {
|
||||
setRecipe(r);
|
||||
const aMap: Record<string, string> = {};
|
||||
for (const a of articles) aMap[a.id] = `${a.name} (${a.articleNumber})`;
|
||||
setArticleMap(aMap);
|
||||
const rMap: Record<string, string> = {};
|
||||
for (const rec of recipes) rMap[rec.id] = `${rec.name} v${rec.version}`;
|
||||
setRecipeMap(rMap);
|
||||
setLoading(false);
|
||||
}).catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
|
||||
}, [recipeId]);
|
||||
|
||||
useEffect(() => { if (recipeId) loadRecipe(); }, [loadRecipe, recipeId]);
|
||||
|
|
@ -98,6 +117,62 @@ export function RecipeDetailScreen() {
|
|||
}
|
||||
if (key.escape) { setMode('menu'); setSelectedIngredientIndex(0); }
|
||||
}
|
||||
|
||||
if (mode === 'reorder-ingredient') {
|
||||
if (key.escape) {
|
||||
setMode('menu');
|
||||
setReorderHeld(null);
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
void handleReorderSave();
|
||||
return;
|
||||
}
|
||||
if (_input === ' ') {
|
||||
if (reorderHeld === null) {
|
||||
setReorderHeld(reorderCursor);
|
||||
} else {
|
||||
setReorderHeld(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.upArrow) {
|
||||
if (reorderHeld !== null) {
|
||||
if (reorderCursor > 0) {
|
||||
setReorderList((list) => {
|
||||
const newList = [...list];
|
||||
const temp = newList[reorderCursor]!;
|
||||
newList[reorderCursor] = newList[reorderCursor - 1]!;
|
||||
newList[reorderCursor - 1] = temp;
|
||||
return newList;
|
||||
});
|
||||
setReorderHeld(reorderCursor - 1);
|
||||
setReorderCursor((c) => c - 1);
|
||||
}
|
||||
} else {
|
||||
setReorderCursor((c) => Math.max(0, c - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
if (reorderHeld !== null) {
|
||||
if (reorderCursor < reorderList.length - 1) {
|
||||
setReorderList((list) => {
|
||||
const newList = [...list];
|
||||
const temp = newList[reorderCursor]!;
|
||||
newList[reorderCursor] = newList[reorderCursor + 1]!;
|
||||
newList[reorderCursor + 1] = temp;
|
||||
return newList;
|
||||
});
|
||||
setReorderHeld(reorderCursor + 1);
|
||||
setReorderCursor((c) => c + 1);
|
||||
}
|
||||
} else {
|
||||
setReorderCursor((c) => Math.min(reorderList.length - 1, c + 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleAction = async () => {
|
||||
|
|
@ -117,6 +192,12 @@ export function RecipeDetailScreen() {
|
|||
setSelectedIngredientIndex(0);
|
||||
setMode('select-ingredient-to-remove');
|
||||
break;
|
||||
case 'reorder-ingredients':
|
||||
setReorderList([...sortedIngredients]);
|
||||
setReorderCursor(0);
|
||||
setReorderHeld(null);
|
||||
setMode('reorder-ingredient');
|
||||
break;
|
||||
case 'add-step':
|
||||
navigate('recipe-add-production-step', { recipeId });
|
||||
break;
|
||||
|
|
@ -140,6 +221,51 @@ export function RecipeDetailScreen() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleReorderSave = useCallback(async () => {
|
||||
if (!recipe) return;
|
||||
|
||||
const hasChanged = reorderList.some((ing, idx) => ing.id !== sortedIngredients[idx]?.id);
|
||||
if (!hasChanged) {
|
||||
setMode('menu');
|
||||
setReorderHeld(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(true);
|
||||
setMode('menu');
|
||||
setReorderHeld(null);
|
||||
|
||||
try {
|
||||
// Remove all ingredients
|
||||
for (const ing of sortedIngredients) {
|
||||
await client.recipes.removeIngredient(recipe.id, ing.id);
|
||||
}
|
||||
// Re-add in new order
|
||||
for (let i = 0; i < reorderList.length; i++) {
|
||||
const ing = reorderList[i]!;
|
||||
await client.recipes.addIngredient(recipe.id, {
|
||||
articleId: ing.articleId,
|
||||
quantity: ing.quantity,
|
||||
uom: ing.uom,
|
||||
position: i + 1,
|
||||
...(ing.subRecipeId ? { subRecipeId: ing.subRecipeId } : {}),
|
||||
substitutable: ing.substitutable,
|
||||
});
|
||||
}
|
||||
const updated = await client.recipes.getById(recipe.id);
|
||||
setRecipe(updated);
|
||||
setSuccessMessage('Zutaten-Reihenfolge aktualisiert.');
|
||||
} catch (err: unknown) {
|
||||
setError(errorMessage(err));
|
||||
// Reload to get consistent state
|
||||
try {
|
||||
const updated = await client.recipes.getById(recipe.id);
|
||||
setRecipe(updated);
|
||||
} catch { /* ignore reload error */ }
|
||||
}
|
||||
setActionLoading(false);
|
||||
}, [recipe, reorderList, sortedIngredients]);
|
||||
|
||||
const handleRemoveStep = useCallback(async () => {
|
||||
if (!recipe || !stepToRemove) return;
|
||||
setMode('menu');
|
||||
|
|
@ -246,15 +372,20 @@ export function RecipeDetailScreen() {
|
|||
<Text color="gray">Ausgabemenge:</Text>
|
||||
<Text>{recipe.outputQuantity} {recipe.outputUom}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Artikel:</Text>
|
||||
<Text>{articleMap[recipe.articleId] ?? recipe.articleId}</Text>
|
||||
</Box>
|
||||
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="gray">Zutaten:</Text>
|
||||
{recipe.ingredients.map((ing) => (
|
||||
{sortedIngredients.map((ing) => (
|
||||
<Box key={ing.id} paddingLeft={2} gap={1}>
|
||||
<Text color="yellow">{ing.position}.</Text>
|
||||
<Text>{ing.quantity} {ing.uom}</Text>
|
||||
<Text color="gray">(Artikel: {ing.articleId})</Text>
|
||||
<Text color="gray">– {articleMap[ing.articleId] ?? ing.articleId}</Text>
|
||||
{ing.subRecipeId && <Text color="blue">[Rezept: {recipeMap[ing.subRecipeId] ?? ing.subRecipeId}]</Text>}
|
||||
{ing.substitutable && <Text color="green">[austauschbar]</Text>}
|
||||
</Box>
|
||||
))}
|
||||
|
|
@ -276,6 +407,25 @@ export function RecipeDetailScreen() {
|
|||
)}
|
||||
</Box>
|
||||
|
||||
{mode === 'reorder-ingredient' && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow" bold>Zutaten neu anordnen:</Text>
|
||||
{reorderList.map((ing, index) => {
|
||||
const isCursor = index === reorderCursor;
|
||||
const isHeld = index === reorderHeld;
|
||||
const prefix = isHeld ? '[*] ' : isCursor ? ' ▶ ' : ' ';
|
||||
return (
|
||||
<Box key={`${ing.id}-${index}`}>
|
||||
<Text color={isCursor ? 'cyan' : isHeld ? 'yellow' : 'white'}>
|
||||
{prefix}{index + 1}. {ing.quantity} {ing.uom} – {articleMap[ing.articleId] ?? ing.articleId}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Text color="gray" dimColor>↑↓ bewegen · Space greifen/loslassen · Enter speichern · Escape abbrechen</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{mode === 'select-step-to-remove' && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow" bold>Schritt zum Entfernen auswählen:</Text>
|
||||
|
|
@ -296,7 +446,7 @@ export function RecipeDetailScreen() {
|
|||
{sortedIngredients.map((ing, index) => (
|
||||
<Box key={ing.id}>
|
||||
<Text color={index === selectedIngredientIndex ? 'cyan' : 'white'}>
|
||||
{index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} (Artikel: {ing.articleId})
|
||||
{index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} – {articleMap[ing.articleId] ?? ing.articleId}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
|
@ -306,7 +456,7 @@ export function RecipeDetailScreen() {
|
|||
|
||||
{mode === 'confirm-remove-ingredient' && ingredientToRemove && (
|
||||
<ConfirmDialog
|
||||
message={`Zutat an Position ${ingredientToRemove.position} (Artikel: ${ingredientToRemove.articleId}) entfernen?`}
|
||||
message={`Zutat an Position ${ingredientToRemove.position} (${articleMap[ingredientToRemove.articleId] ?? ingredientToRemove.articleId}) entfernen?`}
|
||||
onConfirm={() => void handleRemoveIngredient()}
|
||||
onCancel={() => { setMode('menu'); setIngredientToRemove(null); }}
|
||||
/>
|
||||
|
|
@ -356,3 +506,8 @@ export function RecipeDetailScreen() {
|
|||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function sortedIngredientsOf(recipe: RecipeDTO | null): IngredientDTO[] {
|
||||
if (!recipe?.ingredients) return [];
|
||||
return [...recipe.ingredients].sort((a, b) => a.position - b.position);
|
||||
}
|
||||
|
|
|
|||
113
frontend/apps/cli/src/components/shared/ArticlePicker.tsx
Normal file
113
frontend/apps/cli/src/components/shared/ArticlePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ export function ChangePasswordScreen() {
|
|||
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.escape) {
|
||||
if (key.escape || key.backspace) {
|
||||
back();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ type Field = 'username' | 'email' | 'password' | 'roleName';
|
|||
const FIELDS: Field[] = ['username', 'email', 'password', 'roleName'];
|
||||
|
||||
export function UserCreateScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { replace, back } = useNavigation();
|
||||
const { createUser, loading, error, clearError } = useUsers();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
|
|
@ -73,7 +73,7 @@ export function UserCreateScreen() {
|
|||
|
||||
const user = await createUser(username.trim(), email.trim(), password.trim(), roleName.trim() || undefined);
|
||||
if (user) {
|
||||
navigate('user-list');
|
||||
replace('user-list');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ interface NavigationState {
|
|||
|
||||
type NavigationAction =
|
||||
| { type: 'NAVIGATE'; screen: Screen; params?: Record<string, string> }
|
||||
| { type: 'REPLACE'; screen: Screen; params?: Record<string, string> }
|
||||
| { type: 'BACK' };
|
||||
|
||||
function navigationReducer(state: NavigationState, action: NavigationAction): NavigationState {
|
||||
|
|
@ -61,6 +62,12 @@ function navigationReducer(state: NavigationState, action: NavigationAction): Na
|
|||
history: [...state.history, state.current],
|
||||
params: action.params ?? {},
|
||||
};
|
||||
case 'REPLACE':
|
||||
return {
|
||||
current: action.screen,
|
||||
history: state.history,
|
||||
params: action.params ?? {},
|
||||
};
|
||||
case 'BACK': {
|
||||
const history = [...state.history];
|
||||
const previous = history.pop();
|
||||
|
|
@ -75,6 +82,7 @@ interface NavigationContextValue {
|
|||
params: Record<string, string>;
|
||||
canGoBack: boolean;
|
||||
navigate: (screen: Screen, params?: Record<string, string>) => void;
|
||||
replace: (screen: Screen, params?: Record<string, string>) => void;
|
||||
back: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +105,7 @@ export function NavigationProvider({ children, initialScreen = 'login' }: Naviga
|
|||
params: state.params,
|
||||
canGoBack: state.history.length > 0,
|
||||
navigate: (screen, params) => dispatch({ type: 'NAVIGATE', screen, params: params ?? {} }),
|
||||
replace: (screen, params) => dispatch({ type: 'REPLACE', screen, params: params ?? {} }),
|
||||
back: () => dispatch({ type: 'BACK' }),
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -113,8 +113,8 @@ export type {
|
|||
StorageLocationFilter,
|
||||
} from './resources/storage-locations.js';
|
||||
export { STORAGE_TYPE_LABELS } from './resources/storage-locations.js';
|
||||
export type { RecipesResource, RecipeType, RecipeStatus } from './resources/recipes.js';
|
||||
export { RECIPE_TYPE_LABELS } from './resources/recipes.js';
|
||||
export type { RecipesResource, RecipeType, RecipeStatus, UoM } 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 { BATCH_TYPE_LABELS } from './resources/stocks.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,17 @@ export const RECIPE_TYPE_LABELS: Record<RecipeType, string> = {
|
|||
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 {
|
||||
RecipeDTO,
|
||||
RecipeSummaryDTO,
|
||||
|
|
|
|||
|
|
@ -420,6 +420,22 @@ export interface paths {
|
|||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -468,6 +484,54 @@ export interface paths {
|
|||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1171,6 +1235,7 @@ export interface components {
|
|||
shelfLifeDays?: number;
|
||||
outputQuantity: string;
|
||||
outputUom: string;
|
||||
articleId: string;
|
||||
};
|
||||
IngredientResponse: {
|
||||
id: string;
|
||||
|
|
@ -1205,6 +1270,7 @@ export interface components {
|
|||
shelfLifeDays?: number | null;
|
||||
outputQuantity: string;
|
||||
outputUom: string;
|
||||
articleId: string;
|
||||
status: string;
|
||||
ingredients: components["schemas"]["IngredientResponse"][];
|
||||
productionSteps: components["schemas"]["ProductionStepResponse"][];
|
||||
|
|
@ -1231,6 +1297,31 @@ export interface components {
|
|||
subRecipeId?: string;
|
||||
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: {
|
||||
name: string;
|
||||
storageType: string;
|
||||
|
|
@ -1276,6 +1367,13 @@ export interface components {
|
|||
/** Format: date-time */
|
||||
receivedAt?: string;
|
||||
};
|
||||
RemoveStockBatchRequest: {
|
||||
quantityAmount: string;
|
||||
quantityUnit: string;
|
||||
};
|
||||
BlockStockBatchRequest: {
|
||||
reason: string;
|
||||
};
|
||||
CreateCustomerRequest: {
|
||||
name: string;
|
||||
/** @enum {string} */
|
||||
|
|
@ -1383,6 +1481,7 @@ export interface components {
|
|||
shelfLifeDays?: number | null;
|
||||
outputQuantity: string;
|
||||
outputUom: string;
|
||||
articleId: string;
|
||||
status: string;
|
||||
/** Format: int32 */
|
||||
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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue