mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:29:35 +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:
parent
bee3f28b5f
commit
c26d72fbe7
48 changed files with 2090 additions and 474 deletions
|
|
@ -31,6 +31,16 @@ import { CustomerDetailScreen } from './components/masterdata/customers/Customer
|
|||
import { CustomerCreateScreen } from './components/masterdata/customers/CustomerCreateScreen.js';
|
||||
import { AddDeliveryAddressScreen } from './components/masterdata/customers/AddDeliveryAddressScreen.js';
|
||||
import { SetPreferencesScreen } from './components/masterdata/customers/SetPreferencesScreen.js';
|
||||
// Lagerverwaltung
|
||||
import { InventoryMenu } from './components/inventory/InventoryMenu.js';
|
||||
import { StorageLocationListScreen } from './components/inventory/StorageLocationListScreen.js';
|
||||
import { StorageLocationCreateScreen } from './components/inventory/StorageLocationCreateScreen.js';
|
||||
import { StorageLocationDetailScreen } from './components/inventory/StorageLocationDetailScreen.js';
|
||||
// Produktion
|
||||
import { ProductionMenu } from './components/production/ProductionMenu.js';
|
||||
import { RecipeListScreen } from './components/production/RecipeListScreen.js';
|
||||
import { RecipeCreateScreen } from './components/production/RecipeCreateScreen.js';
|
||||
import { RecipeDetailScreen } from './components/production/RecipeDetailScreen.js';
|
||||
|
||||
function ScreenRouter() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
|
@ -87,6 +97,16 @@ function ScreenRouter() {
|
|||
{current === 'customer-create' && <CustomerCreateScreen />}
|
||||
{current === 'customer-add-delivery-address' && <AddDeliveryAddressScreen />}
|
||||
{current === 'customer-set-preferences' && <SetPreferencesScreen />}
|
||||
{/* Lagerverwaltung */}
|
||||
{current === 'inventory-menu' && <InventoryMenu />}
|
||||
{current === 'storage-location-list' && <StorageLocationListScreen />}
|
||||
{current === 'storage-location-create' && <StorageLocationCreateScreen />}
|
||||
{current === 'storage-location-detail' && <StorageLocationDetailScreen />}
|
||||
{/* Produktion */}
|
||||
{current === 'production-menu' && <ProductionMenu />}
|
||||
{current === 'recipe-list' && <RecipeListScreen />}
|
||||
{current === 'recipe-create' && <RecipeCreateScreen />}
|
||||
{current === 'recipe-detail' && <RecipeDetailScreen />}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
64
frontend/apps/cli/src/components/inventory/InventoryMenu.tsx
Normal file
64
frontend/apps/cli/src/components/inventory/InventoryMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
54
frontend/apps/cli/src/hooks/useRecipes.ts
Normal file
54
frontend/apps/cli/src/hooks/useRecipes.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { RecipeDTO, CreateRecipeRequest } from '@effigenix/api-client';
|
||||
import { client } from '../utils/api-client.js';
|
||||
|
||||
interface RecipesState {
|
||||
recipes: RecipeDTO[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
export function useRecipes() {
|
||||
const [state, setState] = useState<RecipesState>({
|
||||
recipes: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchRecipes = useCallback(async () => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const recipes = await client.recipes.list();
|
||||
setState({ recipes, loading: false, error: null });
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createRecipe = useCallback(async (request: CreateRecipeRequest) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const recipe = await client.recipes.create(request);
|
||||
setState((s) => ({ recipes: [...s.recipes, recipe], loading: false, error: null }));
|
||||
return recipe;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState((s) => ({ ...s, error: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchRecipes,
|
||||
createRecipe,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
104
frontend/apps/cli/src/hooks/useStorageLocations.ts
Normal file
104
frontend/apps/cli/src/hooks/useStorageLocations.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type {
|
||||
StorageLocationDTO,
|
||||
CreateStorageLocationRequest,
|
||||
UpdateStorageLocationRequest,
|
||||
StorageLocationFilter,
|
||||
} from '@effigenix/api-client';
|
||||
import { client } from '../utils/api-client.js';
|
||||
|
||||
interface StorageLocationsState {
|
||||
storageLocations: StorageLocationDTO[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
export function useStorageLocations() {
|
||||
const [state, setState] = useState<StorageLocationsState>({
|
||||
storageLocations: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchStorageLocations = useCallback(async (filter?: StorageLocationFilter) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const storageLocations = await client.storageLocations.list(filter);
|
||||
setState({ storageLocations, loading: false, error: null });
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createStorageLocation = useCallback(async (request: CreateStorageLocationRequest) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const location = await client.storageLocations.create(request);
|
||||
setState((s) => ({ storageLocations: [...s.storageLocations, location], loading: false, error: null }));
|
||||
return location;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateStorageLocation = useCallback(async (id: string, request: UpdateStorageLocationRequest) => {
|
||||
try {
|
||||
const updated = await client.storageLocations.update(id, request);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
storageLocations: s.storageLocations.map((loc) => (loc.id === id ? updated : loc)),
|
||||
}));
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, error: errorMessage(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const activateStorageLocation = useCallback(async (id: string) => {
|
||||
try {
|
||||
const updated = await client.storageLocations.activate(id);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
storageLocations: s.storageLocations.map((loc) => (loc.id === id ? updated : loc)),
|
||||
}));
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, error: errorMessage(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deactivateStorageLocation = useCallback(async (id: string) => {
|
||||
try {
|
||||
const updated = await client.storageLocations.deactivate(id);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
storageLocations: s.storageLocations.map((loc) => (loc.id === id ? updated : loc)),
|
||||
}));
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, error: errorMessage(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState((s) => ({ ...s, error: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchStorageLocations,
|
||||
createStorageLocation,
|
||||
updateStorageLocation,
|
||||
activateStorageLocation,
|
||||
deactivateStorageLocation,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
|
@ -27,7 +27,17 @@ export type Screen =
|
|||
| 'customer-detail'
|
||||
| 'customer-create'
|
||||
| 'customer-add-delivery-address'
|
||||
| 'customer-set-preferences';
|
||||
| 'customer-set-preferences'
|
||||
// Lagerverwaltung
|
||||
| 'inventory-menu'
|
||||
| 'storage-location-list'
|
||||
| 'storage-location-create'
|
||||
| 'storage-location-detail'
|
||||
// Produktion
|
||||
| 'production-menu'
|
||||
| 'recipe-list'
|
||||
| 'recipe-create'
|
||||
| 'recipe-detail';
|
||||
|
||||
interface NavigationState {
|
||||
current: Screen;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue