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

feat: TUI-Screens für Inventar und Produktion + API-Client Typ-Migration

Neue TUI-Features:
- Inventar: Lageorte auflisten, anlegen, bearbeiten, (de-)aktivieren
- Produktion: Rezepte auflisten, anlegen, Detail-Ansicht
- Navigation erweitert (Hauptmenü, Routing)

API-Client auf generierte OpenAPI-Typen umgestellt:
- 6 neue Alias-Dateien in @effigenix/types (supplier, category, article,
  customer, inventory, production)
- api-client Re-Exports direkt von @effigenix/types statt via Resources
- Backend: @Schema(requiredProperties) auf 16 Response-Records
- Backend: OpenApiCustomizer für application-layer DTOs (UserDTO, RoleDTO)

Hinweis: Backend-Endpoints für GET /api/recipes und
GET /api/inventory/storage-locations/{id} fehlen noch (separate Issues).
This commit is contained in:
Sebastian Frick 2026-02-19 13:45:35 +01:00
parent bee3f28b5f
commit c26d72fbe7
48 changed files with 2090 additions and 474 deletions

View file

@ -17,6 +17,8 @@ export function MainMenu() {
const items: MenuItem[] = [
{ label: 'Stammdaten', screen: 'masterdata-menu' },
{ label: 'Lagerverwaltung', screen: 'inventory-menu' },
{ label: 'Produktion', screen: 'production-menu' },
{ label: 'Benutzer verwalten', screen: 'user-list' },
{ label: 'Rollen anzeigen', screen: 'role-list' },
{ label: 'Abmelden', action: () => void logout() },

View file

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import type { Screen } from '../../state/navigation-context.js';
interface MenuItem {
label: string;
screen: Screen;
description: string;
}
const MENU_ITEMS: MenuItem[] = [
{ label: 'Lagerorte', screen: 'storage-location-list', description: 'Lagerorte verwalten (Kühlräume, Trockenlager, …)' },
];
export function InventoryMenu() {
const { navigate, back } = useNavigation();
const [selectedIndex, setSelectedIndex] = useState(0);
useInput((_input, key) => {
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex((i) => Math.min(MENU_ITEMS.length - 1, i + 1));
if (key.return) {
const item = MENU_ITEMS[selectedIndex];
if (item) navigate(item.screen);
}
if (key.backspace || key.escape) back();
});
return (
<Box flexDirection="column" paddingY={1}>
<Box marginBottom={1}>
<Text color="cyan" bold>Lagerverwaltung</Text>
</Box>
<Box
flexDirection="column"
borderStyle="round"
borderColor="gray"
paddingX={2}
paddingY={1}
width={50}
>
{MENU_ITEMS.map((item, index) => (
<Box key={item.screen} flexDirection="column">
<Text color={index === selectedIndex ? 'cyan' : 'white'}>
{index === selectedIndex ? '▶ ' : ' '}
{item.label}
</Text>
{index === selectedIndex && (
<Box paddingLeft={4}>
<Text color="gray" dimColor>{item.description}</Text>
</Box>
)}
</Box>
))}
<Box marginTop={1}>
<Text color="gray" dimColor> navigieren · Enter auswählen · Backspace Zurück</Text>
</Box>
</Box>
</Box>
);
}

View file

@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useStorageLocations } from '../../hooks/useStorageLocations.js';
import { FormInput } from '../shared/FormInput.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { STORAGE_TYPE_LABELS } from '@effigenix/api-client';
import type { StorageType } from '@effigenix/api-client';
type Field = 'name' | 'storageType' | 'minTemperature' | 'maxTemperature';
const FIELDS: Field[] = ['name', 'storageType', 'minTemperature', 'maxTemperature'];
const FIELD_LABELS: Record<Field, string> = {
name: 'Name *',
storageType: 'Lagertyp * (←→ wechseln)',
minTemperature: 'Min. Temperatur (°C)',
maxTemperature: 'Max. Temperatur (°C)',
};
const STORAGE_TYPES: StorageType[] = ['COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA'];
export function StorageLocationCreateScreen() {
const { navigate, back } = useNavigation();
const { createStorageLocation, loading, error, clearError } = useStorageLocations();
const [values, setValues] = useState<Record<Field, string>>({
name: '',
storageType: 'DRY_STORAGE',
minTemperature: '',
maxTemperature: '',
});
const [activeField, setActiveField] = useState<Field>('name');
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
const setField = (field: Field) => (value: string) => {
setValues((v) => ({ ...v, [field]: value }));
};
useInput((input, key) => {
if (loading) return;
if (activeField === 'storageType') {
if (key.leftArrow || key.rightArrow) {
const idx = STORAGE_TYPES.indexOf(values.storageType as StorageType);
const dir = key.rightArrow ? 1 : -1;
const next = STORAGE_TYPES[(idx + dir + STORAGE_TYPES.length) % STORAGE_TYPES.length];
if (next) setValues((v) => ({ ...v, storageType: 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.name.trim()) errors.name = 'Name ist erforderlich.';
if (!values.storageType) errors.storageType = 'Lagertyp ist erforderlich.';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const result = await createStorageLocation({
name: values.name.trim(),
storageType: values.storageType,
...(values.minTemperature.trim() ? { minTemperature: values.minTemperature.trim() } : {}),
...(values.maxTemperature.trim() ? { maxTemperature: values.maxTemperature.trim() } : {}),
});
if (result) navigate('storage-location-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}>
<LoadingSpinner label="Lagerort wird angelegt..." />
</Box>
);
}
const storageTypeLabel = STORAGE_TYPE_LABELS[values.storageType as StorageType] ?? values.storageType;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Neuer Lagerort</Text>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => {
if (field === 'storageType') {
return (
<Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: <Text bold color="white">{storageTypeLabel}</Text>
</Text>
{fieldErrors[field] && <Text color="red">{fieldErrors[field]}</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 · Lagertyp · Enter auf letztem Feld speichern · Escape Abbrechen
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,144 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { StorageLocationDTO, StorageType } from '@effigenix/api-client';
import { STORAGE_TYPE_LABELS } from '@effigenix/api-client';
import { useNavigation } from '../../state/navigation-context.js';
import { useStorageLocations } from '../../hooks/useStorageLocations.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { SuccessDisplay } from '../shared/SuccessDisplay.js';
import { ConfirmDialog } from '../shared/ConfirmDialog.js';
import { client } from '../../utils/api-client.js';
type MenuAction = 'toggle-status' | 'back';
type Mode = 'menu' | 'confirm-status';
const MENU_ITEMS: { id: MenuAction; label: (loc: StorageLocationDTO) => string }[] = [
{ id: 'toggle-status', label: (loc) => loc.active ? '[Deaktivieren]' : '[Aktivieren]' },
{ id: 'back', label: () => '[Zurück]' },
];
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function StorageLocationDetailScreen() {
const { params, back } = useNavigation();
const storageLocationId = params['storageLocationId'] ?? '';
const { activateStorageLocation, deactivateStorageLocation } = useStorageLocations();
const [location, setLocation] = useState<StorageLocationDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedAction, setSelectedAction] = useState(0);
const [mode, setMode] = useState<Mode>('menu');
const [actionLoading, setActionLoading] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const loadLocation = useCallback(() => {
setLoading(true);
setError(null);
client.storageLocations.getById(storageLocationId)
.then((loc) => { setLocation(loc); setLoading(false); })
.catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
}, [storageLocationId]);
useEffect(() => { if (storageLocationId) loadLocation(); }, [loadLocation, storageLocationId]);
useInput((_input, key) => {
if (loading || actionLoading) return;
if (mode !== 'menu') return;
if (key.upArrow) setSelectedAction((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedAction((i) => Math.min(MENU_ITEMS.length - 1, i + 1));
if (key.return) void handleAction();
if (key.backspace || key.escape) back();
});
const handleAction = async () => {
if (!location) return;
const item = MENU_ITEMS[selectedAction];
if (!item) return;
switch (item.id) {
case 'toggle-status':
setMode('confirm-status');
break;
case 'back':
back();
break;
}
};
const handleToggleStatus = useCallback(async () => {
if (!location) return;
setMode('menu');
setActionLoading(true);
const fn = location.active ? deactivateStorageLocation : activateStorageLocation;
const updated = await fn(location.id);
setActionLoading(false);
if (updated) {
setLocation(updated);
setSuccessMessage(location.active ? 'Lagerort deaktiviert.' : 'Lagerort aktiviert.');
}
}, [location, activateStorageLocation, deactivateStorageLocation]);
if (loading) return <LoadingSpinner label="Lade Lagerort..." />;
if (error && !location) return <ErrorDisplay message={error} onDismiss={back} />;
if (!location) return <Text color="red">Lagerort nicht gefunden.</Text>;
const statusColor = location.active ? 'green' : 'red';
const typeName = STORAGE_TYPE_LABELS[location.storageType as StorageType] ?? location.storageType;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Lagerort: {location.name}</Text>
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
<Box gap={2}>
<Text color="gray">Status:</Text>
<Text color={statusColor} bold>{location.active ? 'AKTIV' : 'INAKTIV'}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Lagertyp:</Text>
<Text>{typeName}</Text>
</Box>
{location.temperatureRange && (
<Box gap={2}>
<Text color="gray">Temperatur:</Text>
<Text>{location.temperatureRange.minTemperature}°C {location.temperatureRange.maxTemperature}°C</Text>
</Box>
)}
</Box>
{mode === 'confirm-status' && (
<ConfirmDialog
message={location.active ? 'Lagerort deaktivieren?' : 'Lagerort aktivieren?'}
onConfirm={() => void handleToggleStatus()}
onCancel={() => setMode('menu')}
/>
)}
{mode === 'menu' && (
<Box flexDirection="column">
<Text color="gray" bold>Aktionen:</Text>
{actionLoading && <LoadingSpinner label="Aktion wird ausgeführt..." />}
{!actionLoading && MENU_ITEMS.map((item, index) => (
<Box key={item.id}>
<Text color={index === selectedAction ? 'cyan' : 'white'}>
{index === selectedAction ? '▶ ' : ' '}{item.label(location)}
</Text>
</Box>
))}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor> navigieren · Enter ausführen · Backspace Zurück</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useStorageLocations } from '../../hooks/useStorageLocations.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { STORAGE_TYPE_LABELS } from '@effigenix/api-client';
import type { StorageType, StorageLocationFilter } from '@effigenix/api-client';
type Filter = 'ALL' | 'ACTIVE' | 'INACTIVE';
const STORAGE_TYPES: StorageType[] = ['COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA'];
export function StorageLocationListScreen() {
const { navigate, back } = useNavigation();
const { storageLocations, loading, error, fetchStorageLocations, clearError } = useStorageLocations();
const [selectedIndex, setSelectedIndex] = useState(0);
const [statusFilter, setStatusFilter] = useState<Filter>('ALL');
const [typeFilter, setTypeFilter] = useState<StorageType | null>(null);
useEffect(() => {
const filter: StorageLocationFilter = {};
if (typeFilter) filter.storageType = typeFilter;
if (statusFilter !== 'ALL') filter.active = statusFilter === 'ACTIVE';
void fetchStorageLocations(filter);
}, [fetchStorageLocations, statusFilter, typeFilter]);
useInput((input, key) => {
if (loading) return;
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex((i) => Math.min(storageLocations.length - 1, i + 1));
if (key.return && storageLocations.length > 0) {
const loc = storageLocations[selectedIndex];
if (loc) navigate('storage-location-detail', { storageLocationId: loc.id });
}
if (input === 'n') navigate('storage-location-create');
if (input === 'a') { setStatusFilter('ALL'); setSelectedIndex(0); }
if (input === 'A') { setStatusFilter('ACTIVE'); setSelectedIndex(0); }
if (input === 'I') { setStatusFilter('INACTIVE'); setSelectedIndex(0); }
if (input === 't') {
setTypeFilter((current) => {
if (!current) return STORAGE_TYPES[0] ?? null;
const idx = STORAGE_TYPES.indexOf(current);
if (idx >= STORAGE_TYPES.length - 1) return null;
return STORAGE_TYPES[idx + 1] ?? null;
});
setSelectedIndex(0);
}
if (key.backspace || key.escape) back();
});
const filterLabel: Record<Filter, string> = { ALL: 'Alle', ACTIVE: 'Aktiv', INACTIVE: 'Inaktiv' };
const typeLabel = typeFilter ? STORAGE_TYPE_LABELS[typeFilter] : 'Alle';
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Lagerorte</Text>
<Text color="gray" dimColor>
Status: <Text color="yellow">{filterLabel[statusFilter]}</Text>
{' · '}Typ: <Text color="yellow">{typeLabel}</Text>
{' '}({storageLocations.length})
</Text>
</Box>
{loading && <LoadingSpinner label="Lade Lagerorte..." />}
{error && !loading && <ErrorDisplay message={error} onDismiss={clearError} />}
{!loading && !error && (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Box paddingX={1}>
<Text color="gray" bold>{' Status Name'.padEnd(30)}</Text>
<Text color="gray" bold>{'Typ'.padEnd(20)}</Text>
<Text color="gray" bold>Temperatur</Text>
</Box>
{storageLocations.length === 0 && (
<Box paddingX={1} paddingY={1}>
<Text color="gray" dimColor>Keine Lagerorte gefunden.</Text>
</Box>
)}
{storageLocations.map((loc, index) => {
const isSelected = index === selectedIndex;
const statusColor = loc.active ? 'green' : 'red';
const textColor = isSelected ? 'cyan' : 'white';
const typeName = STORAGE_TYPE_LABELS[loc.storageType as StorageType] ?? loc.storageType;
const tempRange = loc.temperatureRange
? `${loc.temperatureRange.minTemperature}°C ${loc.temperatureRange.maxTemperature}°C`
: '';
return (
<Box key={loc.id} paddingX={1}>
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
<Text color={statusColor}>{loc.active ? '● ' : '○ '}</Text>
<Text color={textColor}>{loc.name.substring(0, 24).padEnd(25)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{typeName.padEnd(20)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{tempRange}</Text>
</Box>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · [a] Alle · [A] Aktiv · [I] Inaktiv · [t] Typ · Backspace Zurück
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import type { Screen } from '../../state/navigation-context.js';
interface MenuItem {
label: string;
screen: Screen;
description: string;
}
const MENU_ITEMS: MenuItem[] = [
{ label: 'Rezepte', screen: 'recipe-list', description: 'Rezepte anlegen und verwalten' },
];
export function ProductionMenu() {
const { navigate, back } = useNavigation();
const [selectedIndex, setSelectedIndex] = useState(0);
useInput((_input, key) => {
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex((i) => Math.min(MENU_ITEMS.length - 1, i + 1));
if (key.return) {
const item = MENU_ITEMS[selectedIndex];
if (item) navigate(item.screen);
}
if (key.backspace || key.escape) back();
});
return (
<Box flexDirection="column" paddingY={1}>
<Box marginBottom={1}>
<Text color="cyan" bold>Produktion</Text>
</Box>
<Box
flexDirection="column"
borderStyle="round"
borderColor="gray"
paddingX={2}
paddingY={1}
width={50}
>
{MENU_ITEMS.map((item, index) => (
<Box key={item.screen} flexDirection="column">
<Text color={index === selectedIndex ? 'cyan' : 'white'}>
{index === selectedIndex ? '▶ ' : ' '}
{item.label}
</Text>
{index === selectedIndex && (
<Box paddingLeft={4}>
<Text color="gray" dimColor>{item.description}</Text>
</Box>
)}
</Box>
))}
<Box marginTop={1}>
<Text color="gray" dimColor> navigieren · Enter auswählen · Backspace Zurück</Text>
</Box>
</Box>
</Box>
);
}

View file

@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useRecipes } from '../../hooks/useRecipes.js';
import { FormInput } from '../shared/FormInput.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';
type Field = 'name' | 'version' | 'type' | 'description' | 'yieldPercentage' | 'shelfLifeDays' | 'outputQuantity' | 'outputUom';
const FIELDS: Field[] = ['name', 'version', 'type', 'description', 'yieldPercentage', 'shelfLifeDays', 'outputQuantity', 'outputUom'];
const FIELD_LABELS: Record<Field, string> = {
name: 'Name *',
version: 'Version *',
type: 'Rezepttyp * (←→ wechseln)',
description: 'Beschreibung',
yieldPercentage: 'Ausbeute (%) *',
shelfLifeDays: 'Haltbarkeit (Tage)',
outputQuantity: 'Ausgabemenge *',
outputUom: 'Mengeneinheit *',
};
const RECIPE_TYPES: RecipeType[] = ['RAW_MATERIAL', 'INTERMEDIATE', 'FINISHED_PRODUCT'];
export function RecipeCreateScreen() {
const { navigate, back } = useNavigation();
const { createRecipe, loading, error, clearError } = useRecipes();
const [values, setValues] = useState<Record<Field, string>>({
name: '',
version: '1',
type: 'FINISHED_PRODUCT',
description: '',
yieldPercentage: '100',
shelfLifeDays: '',
outputQuantity: '',
outputUom: '',
});
const [activeField, setActiveField] = useState<Field>('name');
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
const setField = (field: Field) => (value: string) => {
setValues((v) => ({ ...v, [field]: value }));
};
useInput((input, key) => {
if (loading) return;
if (activeField === 'type') {
if (key.leftArrow || key.rightArrow) {
const idx = RECIPE_TYPES.indexOf(values.type as RecipeType);
const dir = key.rightArrow ? 1 : -1;
const next = RECIPE_TYPES[(idx + dir + RECIPE_TYPES.length) % RECIPE_TYPES.length];
if (next) setValues((v) => ({ ...v, type: 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.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}>
<LoadingSpinner label="Rezept wird angelegt..." />
</Box>
);
}
const typeLabel = RECIPE_TYPE_LABELS[values.type as RecipeType] ?? values.type;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Neues Rezept</Text>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => {
if (field === 'type') {
return (
<Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: <Text bold color="white">{typeLabel}</Text>
</Text>
{fieldErrors[field] && <Text color="red">{fieldErrors[field]}</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 · Rezepttyp · Enter auf letztem Feld speichern · Escape Abbrechen
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,103 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { RecipeDTO, RecipeType } from '@effigenix/api-client';
import { RECIPE_TYPE_LABELS } from '@effigenix/api-client';
import { useNavigation } from '../../state/navigation-context.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { client } from '../../utils/api-client.js';
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function RecipeDetailScreen() {
const { params, back } = useNavigation();
const recipeId = params['recipeId'] ?? '';
const [recipe, setRecipe] = useState<RecipeDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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); });
}, [recipeId]);
useEffect(() => { if (recipeId) loadRecipe(); }, [loadRecipe, recipeId]);
useInput((_input, key) => {
if (loading) return;
if (key.backspace || key.escape) back();
});
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>;
const typeName = RECIPE_TYPE_LABELS[recipe.type as RecipeType] ?? recipe.type;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Rezept: {recipe.name}</Text>
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
<Box gap={2}>
<Text color="gray">Status:</Text>
<Text bold>{recipe.status}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Typ:</Text>
<Text>{typeName}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Version:</Text>
<Text>{recipe.version}</Text>
</Box>
{recipe.description && (
<Box gap={2}>
<Text color="gray">Beschreibung:</Text>
<Text>{recipe.description}</Text>
</Box>
)}
<Box gap={2}>
<Text color="gray">Ausbeute:</Text>
<Text>{recipe.yieldPercentage}%</Text>
</Box>
{recipe.shelfLifeDays !== null && (
<Box gap={2}>
<Text color="gray">Haltbarkeit:</Text>
<Text>{recipe.shelfLifeDays} Tage</Text>
</Box>
)}
<Box gap={2}>
<Text color="gray">Ausgabemenge:</Text>
<Text>{recipe.outputQuantity} {recipe.outputUom}</Text>
</Box>
{recipe.ingredients.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Zutaten:</Text>
{recipe.ingredients.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>
{ing.substitutable && <Text color="green">[austauschbar]</Text>}
</Box>
))}
</Box>
)}
</Box>
<Box marginTop={1}>
<Text color="gray" dimColor>Backspace Zurück</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,80 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
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';
export function RecipeListScreen() {
const { navigate, back } = useNavigation();
const { recipes, loading, error, fetchRecipes, clearError } = useRecipes();
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
void fetchRecipes();
}, [fetchRecipes]);
useInput((input, key) => {
if (loading) return;
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex((i) => Math.min(recipes.length - 1, i + 1));
if (key.return && recipes.length > 0) {
const recipe = recipes[selectedIndex];
if (recipe) navigate('recipe-detail', { recipeId: recipe.id });
}
if (input === 'n') navigate('recipe-create');
if (key.backspace || key.escape) back();
});
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Rezepte</Text>
<Text color="gray" dimColor>({recipes.length})</Text>
</Box>
{loading && <LoadingSpinner label="Lade Rezepte..." />}
{error && !loading && <ErrorDisplay message={error} onDismiss={clearError} />}
{!loading && !error && (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Box paddingX={1}>
<Text color="gray" bold>{' Name'.padEnd(30)}</Text>
<Text color="gray" bold>{'Typ'.padEnd(18)}</Text>
<Text color="gray" bold>{'V.'.padEnd(5)}</Text>
<Text color="gray" bold>Status</Text>
</Box>
{recipes.length === 0 && (
<Box paddingX={1} paddingY={1}>
<Text color="gray" dimColor>Keine Rezepte gefunden.</Text>
</Box>
)}
{recipes.map((recipe, index) => {
const isSelected = index === selectedIndex;
const textColor = isSelected ? 'cyan' : 'white';
const typeName = RECIPE_TYPE_LABELS[recipe.type as RecipeType] ?? recipe.type;
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>
</Box>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · Backspace Zurück
</Text>
</Box>
</Box>
);
}