1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 18:49:59 +01:00

feat(production): articleId für Rezepte, TUI-Verbesserungen mit UoM-Carousel, ArticlePicker und Zutaten-Reorder

Backend:
- articleId als Pflichtfeld im Recipe-Aggregate (Domain, Application, Infrastructure)
- Liquibase-Migration 015 mit defaultValue für bestehende Daten
- Alle Tests angepasst (Unit, Integration)

Frontend:
- UoM-Carousel-Selektor in RecipeCreateScreen, AddBatchScreen, AddIngredientScreen
- ArticlePicker-Komponente mit Typeahead-Suche für Artikelauswahl
- Auto-Position bei Zutatenzugabe (kein manuelles Feld mehr)
- Automatische subRecipeId-Erkennung bei Artikelauswahl
- Zutaten-Reorder per Drag im RecipeDetailScreen (Remove + Re-Add)
- Artikelnamen statt UUIDs in der Rezept-Detailansicht
- Navigation-Context: replace()-Methode ergänzt
This commit is contained in:
Sebastian Frick 2026-02-20 01:07:32 +01:00
parent b46495e1aa
commit 6c1e6c24bc
48 changed files with 999 additions and 237 deletions

View file

@ -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>

View file

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

View file

@ -21,7 +21,7 @@ const FIELD_LABELS: Record<Field, string> = {
const STORAGE_TYPES: StorageType[] = ['COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA'];
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) => {

View file

@ -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>;

View file

@ -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>;

View file

@ -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) => {

View file

@ -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) => {

View file

@ -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) => {

View file

@ -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} />;

View file

@ -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) => {

View file

@ -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);
}
};

View file

@ -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) => {

View file

@ -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>

View file

@ -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);

View file

@ -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>

View file

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { RecipeDTO, RecipeType, ProductionStepDTO, IngredientDTO } from '@effigenix/api-client';
import type { RecipeDTO, RecipeType, RecipeSummaryDTO, ProductionStepDTO, IngredientDTO, ArticleDTO } from '@effigenix/api-client';
import { RECIPE_TYPE_LABELS } from '@effigenix/api-client';
import { useNavigation } from '../../state/navigation-context.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
@ -9,8 +9,8 @@ import { SuccessDisplay } from '../shared/SuccessDisplay.js';
import { ConfirmDialog } from '../shared/ConfirmDialog.js';
import { client } from '../../utils/api-client.js';
type MenuAction = 'add-ingredient' | 'remove-ingredient' | 'add-step' | 'remove-step' | 'activate' | 'archive' | 'back';
type Mode = 'menu' | 'select-step-to-remove' | 'confirm-remove' | 'select-ingredient-to-remove' | 'confirm-remove-ingredient' | 'confirm-activate' | 'confirm-archive';
type MenuAction = 'add-ingredient' | 'remove-ingredient' | 'reorder-ingredients' | 'add-step' | 'remove-step' | 'activate' | 'archive' | 'back';
type Mode = 'menu' | 'select-step-to-remove' | 'confirm-remove' | 'select-ingredient-to-remove' | 'confirm-remove-ingredient' | 'confirm-activate' | 'confirm-archive' | 'reorder-ingredient';
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
@ -31,6 +31,13 @@ export function RecipeDetailScreen() {
const [stepToRemove, setStepToRemove] = useState<ProductionStepDTO | null>(null);
const [selectedIngredientIndex, setSelectedIngredientIndex] = useState(0);
const [ingredientToRemove, setIngredientToRemove] = useState<IngredientDTO | null>(null);
const [articleMap, setArticleMap] = useState<Record<string, string>>({});
const [recipeMap, setRecipeMap] = useState<Record<string, string>>({});
// Reorder state
const [reorderList, setReorderList] = useState<IngredientDTO[]>([]);
const [reorderCursor, setReorderCursor] = useState(0);
const [reorderHeld, setReorderHeld] = useState<number | null>(null);
const isDraft = recipe?.status === 'DRAFT';
const isActive = recipe?.status === 'ACTIVE';
@ -39,6 +46,9 @@ export function RecipeDetailScreen() {
...(isDraft ? [
{ id: 'add-ingredient' as const, label: '[Zutat hinzufügen]' },
{ id: 'remove-ingredient' as const, label: '[Zutat entfernen]' },
...(sortedIngredientsOf(recipe).length >= 2
? [{ id: 'reorder-ingredients' as const, label: '[Zutaten neu anordnen]' }]
: []),
{ id: 'add-step' as const, label: '[Schritt hinzufügen]' },
{ id: 'remove-step' as const, label: '[Schritt entfernen]' },
{ id: 'activate' as const, label: '[Rezept aktivieren]' },
@ -55,16 +65,25 @@ export function RecipeDetailScreen() {
? [...recipe.productionSteps].sort((a, b) => a.stepNumber - b.stepNumber)
: [];
const sortedIngredients = recipe?.ingredients
? [...recipe.ingredients].sort((a, b) => a.position - b.position)
: [];
const sortedIngredients = sortedIngredientsOf(recipe);
const loadRecipe = useCallback(() => {
setLoading(true);
setError(null);
client.recipes.getById(recipeId)
.then((r) => { setRecipe(r); setLoading(false); })
.catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
Promise.all([
client.recipes.getById(recipeId),
client.articles.list().catch(() => [] as ArticleDTO[]),
client.recipes.list().catch(() => [] as RecipeSummaryDTO[]),
]).then(([r, articles, recipes]) => {
setRecipe(r);
const aMap: Record<string, string> = {};
for (const a of articles) aMap[a.id] = `${a.name} (${a.articleNumber})`;
setArticleMap(aMap);
const rMap: Record<string, string> = {};
for (const rec of recipes) rMap[rec.id] = `${rec.name} v${rec.version}`;
setRecipeMap(rMap);
setLoading(false);
}).catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
}, [recipeId]);
useEffect(() => { if (recipeId) loadRecipe(); }, [loadRecipe, recipeId]);
@ -98,6 +117,62 @@ export function RecipeDetailScreen() {
}
if (key.escape) { setMode('menu'); setSelectedIngredientIndex(0); }
}
if (mode === 'reorder-ingredient') {
if (key.escape) {
setMode('menu');
setReorderHeld(null);
return;
}
if (key.return) {
void handleReorderSave();
return;
}
if (_input === ' ') {
if (reorderHeld === null) {
setReorderHeld(reorderCursor);
} else {
setReorderHeld(null);
}
return;
}
if (key.upArrow) {
if (reorderHeld !== null) {
if (reorderCursor > 0) {
setReorderList((list) => {
const newList = [...list];
const temp = newList[reorderCursor]!;
newList[reorderCursor] = newList[reorderCursor - 1]!;
newList[reorderCursor - 1] = temp;
return newList;
});
setReorderHeld(reorderCursor - 1);
setReorderCursor((c) => c - 1);
}
} else {
setReorderCursor((c) => Math.max(0, c - 1));
}
return;
}
if (key.downArrow) {
if (reorderHeld !== null) {
if (reorderCursor < reorderList.length - 1) {
setReorderList((list) => {
const newList = [...list];
const temp = newList[reorderCursor]!;
newList[reorderCursor] = newList[reorderCursor + 1]!;
newList[reorderCursor + 1] = temp;
return newList;
});
setReorderHeld(reorderCursor + 1);
setReorderCursor((c) => c + 1);
}
} else {
setReorderCursor((c) => Math.min(reorderList.length - 1, c + 1));
}
return;
}
}
});
const handleAction = async () => {
@ -117,6 +192,12 @@ export function RecipeDetailScreen() {
setSelectedIngredientIndex(0);
setMode('select-ingredient-to-remove');
break;
case 'reorder-ingredients':
setReorderList([...sortedIngredients]);
setReorderCursor(0);
setReorderHeld(null);
setMode('reorder-ingredient');
break;
case 'add-step':
navigate('recipe-add-production-step', { recipeId });
break;
@ -140,6 +221,51 @@ export function RecipeDetailScreen() {
}
};
const handleReorderSave = useCallback(async () => {
if (!recipe) return;
const hasChanged = reorderList.some((ing, idx) => ing.id !== sortedIngredients[idx]?.id);
if (!hasChanged) {
setMode('menu');
setReorderHeld(null);
return;
}
setActionLoading(true);
setMode('menu');
setReorderHeld(null);
try {
// Remove all ingredients
for (const ing of sortedIngredients) {
await client.recipes.removeIngredient(recipe.id, ing.id);
}
// Re-add in new order
for (let i = 0; i < reorderList.length; i++) {
const ing = reorderList[i]!;
await client.recipes.addIngredient(recipe.id, {
articleId: ing.articleId,
quantity: ing.quantity,
uom: ing.uom,
position: i + 1,
...(ing.subRecipeId ? { subRecipeId: ing.subRecipeId } : {}),
substitutable: ing.substitutable,
});
}
const updated = await client.recipes.getById(recipe.id);
setRecipe(updated);
setSuccessMessage('Zutaten-Reihenfolge aktualisiert.');
} catch (err: unknown) {
setError(errorMessage(err));
// Reload to get consistent state
try {
const updated = await client.recipes.getById(recipe.id);
setRecipe(updated);
} catch { /* ignore reload error */ }
}
setActionLoading(false);
}, [recipe, reorderList, sortedIngredients]);
const handleRemoveStep = useCallback(async () => {
if (!recipe || !stepToRemove) return;
setMode('menu');
@ -246,15 +372,20 @@ export function RecipeDetailScreen() {
<Text color="gray">Ausgabemenge:</Text>
<Text>{recipe.outputQuantity} {recipe.outputUom}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Artikel:</Text>
<Text>{articleMap[recipe.articleId] ?? recipe.articleId}</Text>
</Box>
{recipe.ingredients.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Zutaten:</Text>
{recipe.ingredients.map((ing) => (
{sortedIngredients.map((ing) => (
<Box key={ing.id} paddingLeft={2} gap={1}>
<Text color="yellow">{ing.position}.</Text>
<Text>{ing.quantity} {ing.uom}</Text>
<Text color="gray">(Artikel: {ing.articleId})</Text>
<Text color="gray"> {articleMap[ing.articleId] ?? ing.articleId}</Text>
{ing.subRecipeId && <Text color="blue">[Rezept: {recipeMap[ing.subRecipeId] ?? ing.subRecipeId}]</Text>}
{ing.substitutable && <Text color="green">[austauschbar]</Text>}
</Box>
))}
@ -276,6 +407,25 @@ export function RecipeDetailScreen() {
)}
</Box>
{mode === 'reorder-ingredient' && (
<Box flexDirection="column">
<Text color="yellow" bold>Zutaten neu anordnen:</Text>
{reorderList.map((ing, index) => {
const isCursor = index === reorderCursor;
const isHeld = index === reorderHeld;
const prefix = isHeld ? '[*] ' : isCursor ? ' ▶ ' : ' ';
return (
<Box key={`${ing.id}-${index}`}>
<Text color={isCursor ? 'cyan' : isHeld ? 'yellow' : 'white'}>
{prefix}{index + 1}. {ing.quantity} {ing.uom} {articleMap[ing.articleId] ?? ing.articleId}
</Text>
</Box>
);
})}
<Text color="gray" dimColor> bewegen · Space greifen/loslassen · Enter speichern · Escape abbrechen</Text>
</Box>
)}
{mode === 'select-step-to-remove' && (
<Box flexDirection="column">
<Text color="yellow" bold>Schritt zum Entfernen auswählen:</Text>
@ -296,7 +446,7 @@ export function RecipeDetailScreen() {
{sortedIngredients.map((ing, index) => (
<Box key={ing.id}>
<Text color={index === selectedIngredientIndex ? 'cyan' : 'white'}>
{index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} (Artikel: {ing.articleId})
{index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} {articleMap[ing.articleId] ?? ing.articleId}
</Text>
</Box>
))}
@ -306,7 +456,7 @@ export function RecipeDetailScreen() {
{mode === 'confirm-remove-ingredient' && ingredientToRemove && (
<ConfirmDialog
message={`Zutat an Position ${ingredientToRemove.position} (Artikel: ${ingredientToRemove.articleId}) entfernen?`}
message={`Zutat an Position ${ingredientToRemove.position} (${articleMap[ingredientToRemove.articleId] ?? ingredientToRemove.articleId}) entfernen?`}
onConfirm={() => void handleRemoveIngredient()}
onCancel={() => { setMode('menu'); setIngredientToRemove(null); }}
/>
@ -356,3 +506,8 @@ export function RecipeDetailScreen() {
</Box>
);
}
function sortedIngredientsOf(recipe: RecipeDTO | null): IngredientDTO[] {
if (!recipe?.ingredients) return [];
return [...recipe.ingredients].sort((a, b) => a.position - b.position);
}

View file

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

View file

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

View file

@ -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');
}
};