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

feat(frontend): TUI-Screens für Rezept-Filter, Archivierung und Chargen-Einbuchung

Production: Rezeptliste mit Status-Filter (Draft/Active/Archived), Rezept
archivieren für aktive Rezepte, list() gibt RecipeSummaryDTO zurück.
Inventory: Charge einbuchen (AddBatch) mit neuem Stocks-Resource und Screens.
This commit is contained in:
Sebastian Frick 2026-02-19 22:46:38 +01:00
parent f2003a3093
commit ec736cf294
16 changed files with 553 additions and 16 deletions

View file

@ -0,0 +1,150 @@
import { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { FormInput } from '../shared/FormInput.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { 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';
type Field = 'batchId' | 'batchType' | 'quantityAmount' | 'quantityUnit' | 'expiryDate';
const FIELDS: Field[] = ['batchId', 'batchType', 'quantityAmount', 'quantityUnit', 'expiryDate'];
const FIELD_LABELS: Record<Field, string> = {
batchId: 'Chargen-Nr. *',
batchType: 'Chargentyp *',
quantityAmount: 'Menge *',
quantityUnit: 'Einheit *',
expiryDate: 'Ablaufdatum (YYYY-MM-DD) *',
};
const BATCH_TYPES: BatchType[] = ['PURCHASED', 'PRODUCED'];
export function AddBatchScreen() {
const { params, navigate, 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 [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) => {
setValues((v) => ({ ...v, [field]: value }));
};
useInput((_input, key) => {
if (loading) return;
if (activeField === 'batchType') {
if (key.leftArrow || key.rightArrow) {
const idx = BATCH_TYPES.indexOf(values.batchType as BatchType);
const next = key.rightArrow
? BATCH_TYPES[(idx + 1) % BATCH_TYPES.length]
: BATCH_TYPES[(idx - 1 + BATCH_TYPES.length) % BATCH_TYPES.length];
if (next) setValues((v) => ({ ...v, batchType: next }));
return;
}
}
if (key.tab || key.downArrow) {
setActiveField((f) => {
const idx = FIELDS.indexOf(f);
return FIELDS[(idx + 1) % FIELDS.length] ?? f;
});
}
if (key.upArrow) {
setActiveField((f) => {
const idx = FIELDS.indexOf(f);
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
});
}
if (key.escape) back();
});
const handleSubmit = async () => {
const errors: Partial<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>;
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 && (
<Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => {
if (field === 'batchType') {
const typeName = BATCH_TYPE_LABELS[values.batchType as BatchType] ?? values.batchType;
return (
<Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: {activeField === field ? `${typeName}` : typeName}
</Text>
</Box>
);
}
return (
<FormInput
key={field}
label={FIELD_LABELS[field]}
value={values[field]}
onChange={setField(field)}
onSubmit={handleFieldSubmit(field)}
focus={activeField === field}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
/>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
Tab/ Feld wechseln · Typ wählen · Enter auf letztem Feld speichern · Escape Abbrechen
</Text>
</Box>
</Box>
);
}

View file

@ -11,6 +11,7 @@ interface MenuItem {
const MENU_ITEMS: MenuItem[] = [
{ label: 'Lagerorte', screen: 'storage-location-list', description: 'Lagerorte verwalten (Kühlräume, Trockenlager, …)' },
{ label: 'Charge einbuchen', screen: 'stock-batch-entry', description: 'Neue Charge in einen Bestand einbuchen' },
];
export function InventoryMenu() {

View file

@ -0,0 +1,51 @@
import { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { FormInput } from '../shared/FormInput.js';
export function StockBatchEntryScreen() {
const { navigate, back } = useNavigation();
const [stockId, setStockId] = useState('');
const [error, setError] = useState<string | null>(null);
const handleChange = (value: string) => {
setStockId(value);
if (error) setError(null);
};
useInput((_input, key) => {
if (key.escape) back();
});
const handleSubmit = () => {
if (!stockId.trim()) {
setError('Bestand-ID ist erforderlich.');
return;
}
navigate('stock-add-batch', { stockId: stockId.trim() });
};
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Charge einbuchen</Text>
<Text color="gray">Geben Sie die Bestand-ID ein, um eine neue Charge einzubuchen.</Text>
<Box flexDirection="column" width={60}>
<FormInput
label="Bestand-ID *"
value={stockId}
onChange={handleChange}
onSubmit={handleSubmit}
focus={true}
{...(error ? { error } : {})}
/>
</Box>
<Box marginTop={1}>
<Text color="gray" dimColor>
Enter Weiter · Escape Zurück
</Text>
</Box>
</Box>
);
}

View file

@ -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' | 'back';
type Mode = 'menu' | 'select-step-to-remove' | 'confirm-remove' | 'select-ingredient-to-remove' | 'confirm-remove-ingredient' | 'confirm-activate';
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';
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
@ -33,6 +33,7 @@ export function RecipeDetailScreen() {
const [ingredientToRemove, setIngredientToRemove] = useState<IngredientDTO | null>(null);
const isDraft = recipe?.status === 'DRAFT';
const isActive = recipe?.status === 'ACTIVE';
const menuItems: { id: MenuAction; label: string }[] = [
...(isDraft ? [
@ -42,6 +43,9 @@ export function RecipeDetailScreen() {
{ id: 'remove-step' as const, label: '[Schritt entfernen]' },
{ id: 'activate' as const, label: '[Rezept aktivieren]' },
] : []),
...(isActive ? [
{ id: 'archive' as const, label: '[Rezept archivieren]' },
] : []),
{ id: 'back' as const, label: '[Zurück]' },
];
@ -127,6 +131,9 @@ export function RecipeDetailScreen() {
case 'activate':
setMode('confirm-activate');
break;
case 'archive':
setMode('confirm-archive');
break;
case 'back':
back();
break;
@ -179,6 +186,20 @@ export function RecipeDetailScreen() {
setActionLoading(false);
}, [recipe]);
const handleArchive = useCallback(async () => {
if (!recipe) return;
setMode('menu');
setActionLoading(true);
try {
const updated = await client.recipes.archiveRecipe(recipe.id);
setRecipe(updated);
setSuccessMessage('Rezept wurde archiviert.');
} catch (err: unknown) {
setError(errorMessage(err));
}
setActionLoading(false);
}, [recipe]);
if (loading) return <LoadingSpinner label="Lade Rezept..." />;
if (error && !recipe) return <ErrorDisplay message={error} onDismiss={back} />;
if (!recipe) return <Text color="red">Rezept nicht gefunden.</Text>;
@ -307,6 +328,14 @@ export function RecipeDetailScreen() {
/>
)}
{mode === 'confirm-archive' && (
<ConfirmDialog
message="Rezept wirklich archivieren? Der Status wechselt zu ARCHIVED."
onConfirm={() => void handleArchive()}
onCancel={() => setMode('menu')}
/>
)}
{mode === 'menu' && (
<Box flexDirection="column">
<Text color="gray" bold>Aktionen:</Text>

View file

@ -5,16 +5,30 @@ import { useRecipes } from '../../hooks/useRecipes.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 type { RecipeType, RecipeStatus } from '@effigenix/api-client';
const STATUS_FILTERS: { key: string; label: string; value: RecipeStatus | undefined }[] = [
{ key: 'a', label: 'ALLE', value: undefined },
{ key: 'D', label: 'DRAFT', value: 'DRAFT' },
{ key: 'A', label: 'ACTIVE', value: 'ACTIVE' },
{ key: 'R', label: 'ARCHIVED', value: 'ARCHIVED' },
];
const STATUS_COLORS: Record<string, string> = {
DRAFT: 'yellow',
ACTIVE: 'green',
ARCHIVED: 'gray',
};
export function RecipeListScreen() {
const { navigate, back } = useNavigation();
const { recipes, loading, error, fetchRecipes, clearError } = useRecipes();
const [selectedIndex, setSelectedIndex] = useState(0);
const [statusFilter, setStatusFilter] = useState<RecipeStatus | undefined>(undefined);
useEffect(() => {
void fetchRecipes();
}, [fetchRecipes]);
void fetchRecipes(statusFilter);
}, [fetchRecipes, statusFilter]);
useInput((input, key) => {
if (loading) return;
@ -28,13 +42,26 @@ export function RecipeListScreen() {
}
if (input === 'n') navigate('recipe-create');
if (key.backspace || key.escape) back();
// Status-Filter
for (const filter of STATUS_FILTERS) {
if (input === filter.key) {
setStatusFilter(filter.value);
setSelectedIndex(0);
break;
}
}
});
const activeFilterLabel = STATUS_FILTERS.find((f) => f.value === statusFilter)?.label ?? 'ALLE';
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Rezepte</Text>
<Text color="gray" dimColor>({recipes.length})</Text>
<Text color="gray" dimColor>Filter: </Text>
<Text color="yellow" bold>{activeFilterLabel}</Text>
</Box>
{loading && <LoadingSpinner label="Lade Rezepte..." />}
@ -57,13 +84,14 @@ export function RecipeListScreen() {
const isSelected = index === selectedIndex;
const textColor = isSelected ? 'cyan' : 'white';
const typeName = RECIPE_TYPE_LABELS[recipe.type as RecipeType] ?? recipe.type;
const statusColor = STATUS_COLORS[recipe.status] ?? 'white';
return (
<Box key={recipe.id} paddingX={1}>
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
<Text color={textColor}>{recipe.name.substring(0, 26).padEnd(27)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{typeName.padEnd(18)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{String(recipe.version).padEnd(5)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{recipe.status}</Text>
<Text color={isSelected ? 'cyan' : statusColor}>{recipe.status}</Text>
</Box>
);
})}
@ -72,7 +100,7 @@ export function RecipeListScreen() {
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · Backspace Zurück
nav · Enter Details · [n] Neu · [a] Alle [D] Draft [A] Active [R] Archived · Backspace Zurück
</Text>
</Box>
</Box>