mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:19:35 +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:
parent
f2003a3093
commit
ec736cf294
16 changed files with 553 additions and 16 deletions
|
|
@ -36,6 +36,8 @@ 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';
|
||||
import { StockBatchEntryScreen } from './components/inventory/StockBatchEntryScreen.js';
|
||||
import { AddBatchScreen } from './components/inventory/AddBatchScreen.js';
|
||||
// Produktion
|
||||
import { ProductionMenu } from './components/production/ProductionMenu.js';
|
||||
import { RecipeListScreen } from './components/production/RecipeListScreen.js';
|
||||
|
|
@ -104,6 +106,8 @@ function ScreenRouter() {
|
|||
{current === 'storage-location-list' && <StorageLocationListScreen />}
|
||||
{current === 'storage-location-create' && <StorageLocationCreateScreen />}
|
||||
{current === 'storage-location-detail' && <StorageLocationDetailScreen />}
|
||||
{current === 'stock-batch-entry' && <StockBatchEntryScreen />}
|
||||
{current === 'stock-add-batch' && <AddBatchScreen />}
|
||||
{/* Produktion */}
|
||||
{current === 'production-menu' && <ProductionMenu />}
|
||||
{current === 'recipe-list' && <RecipeListScreen />}
|
||||
|
|
|
|||
150
frontend/apps/cli/src/components/inventory/AddBatchScreen.tsx
Normal file
150
frontend/apps/cli/src/components/inventory/AddBatchScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { RecipeDTO, CreateRecipeRequest } from '@effigenix/api-client';
|
||||
import type { RecipeSummaryDTO, CreateRecipeRequest, RecipeStatus } from '@effigenix/api-client';
|
||||
import { client } from '../utils/api-client.js';
|
||||
|
||||
interface RecipesState {
|
||||
recipes: RecipeDTO[];
|
||||
recipes: RecipeSummaryDTO[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -19,10 +19,10 @@ export function useRecipes() {
|
|||
error: null,
|
||||
});
|
||||
|
||||
const fetchRecipes = useCallback(async () => {
|
||||
const fetchRecipes = useCallback(async (status?: RecipeStatus) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const recipes = await client.recipes.list();
|
||||
const recipes = await client.recipes.list(status);
|
||||
setState({ recipes, loading: false, error: null });
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
|
|
@ -33,7 +33,8 @@ export function useRecipes() {
|
|||
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 }));
|
||||
const recipes = await client.recipes.list();
|
||||
setState({ recipes, loading: false, error: null });
|
||||
return recipe;
|
||||
} catch (err) {
|
||||
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||
|
|
|
|||
41
frontend/apps/cli/src/hooks/useStocks.ts
Normal file
41
frontend/apps/cli/src/hooks/useStocks.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { StockBatchDTO, AddStockBatchRequest } from '@effigenix/api-client';
|
||||
import { client } from '../utils/api-client.js';
|
||||
|
||||
interface StocksState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
export function useStocks() {
|
||||
const [state, setState] = useState<StocksState>({
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const addBatch = useCallback(async (stockId: string, request: AddStockBatchRequest): Promise<StockBatchDTO | null> => {
|
||||
setState({ loading: true, error: null });
|
||||
try {
|
||||
const batch = await client.stocks.addBatch(stockId, request);
|
||||
setState({ loading: false, error: null });
|
||||
return batch;
|
||||
} catch (err) {
|
||||
setState({ loading: false, error: errorMessage(err) });
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState((s) => ({ ...s, error: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
addBatch,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
|
@ -33,6 +33,8 @@ export type Screen =
|
|||
| 'storage-location-list'
|
||||
| 'storage-location-create'
|
||||
| 'storage-location-detail'
|
||||
| 'stock-batch-entry'
|
||||
| 'stock-add-batch'
|
||||
// Produktion
|
||||
| 'production-menu'
|
||||
| 'recipe-list'
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -24,6 +24,7 @@ export { createArticlesResource } from './resources/articles.js';
|
|||
export { createCustomersResource } from './resources/customers.js';
|
||||
export { createStorageLocationsResource } from './resources/storage-locations.js';
|
||||
export { createRecipesResource } from './resources/recipes.js';
|
||||
export { createStocksResource } from './resources/stocks.js';
|
||||
export {
|
||||
ApiError,
|
||||
AuthenticationError,
|
||||
|
|
@ -81,11 +82,14 @@ export type {
|
|||
CreateStorageLocationRequest,
|
||||
UpdateStorageLocationRequest,
|
||||
RecipeDTO,
|
||||
RecipeSummaryDTO,
|
||||
IngredientDTO,
|
||||
ProductionStepDTO,
|
||||
CreateRecipeRequest,
|
||||
AddRecipeIngredientRequest,
|
||||
AddProductionStepRequest,
|
||||
StockBatchDTO,
|
||||
AddStockBatchRequest,
|
||||
} from '@effigenix/types';
|
||||
|
||||
// Resource types (runtime, stay in resource files)
|
||||
|
|
@ -111,6 +115,8 @@ export type {
|
|||
export { STORAGE_TYPE_LABELS } from './resources/storage-locations.js';
|
||||
export type { RecipesResource, RecipeType, RecipeStatus } from './resources/recipes.js';
|
||||
export { RECIPE_TYPE_LABELS } from './resources/recipes.js';
|
||||
export type { StocksResource, BatchType } from './resources/stocks.js';
|
||||
export { BATCH_TYPE_LABELS } from './resources/stocks.js';
|
||||
|
||||
import { createApiClient } from './client.js';
|
||||
import { createAuthResource } from './resources/auth.js';
|
||||
|
|
@ -122,6 +128,7 @@ import { createArticlesResource } from './resources/articles.js';
|
|||
import { createCustomersResource } from './resources/customers.js';
|
||||
import { createStorageLocationsResource } from './resources/storage-locations.js';
|
||||
import { createRecipesResource } from './resources/recipes.js';
|
||||
import { createStocksResource } from './resources/stocks.js';
|
||||
import type { TokenProvider } from './token-provider.js';
|
||||
import type { ApiConfig } from '@effigenix/config';
|
||||
|
||||
|
|
@ -145,6 +152,7 @@ export function createEffigenixClient(
|
|||
customers: createCustomersResource(axiosClient),
|
||||
storageLocations: createStorageLocationsResource(axiosClient),
|
||||
recipes: createRecipesResource(axiosClient),
|
||||
stocks: createStocksResource(axiosClient),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type { AxiosInstance } from 'axios';
|
||||
import type {
|
||||
RecipeDTO,
|
||||
RecipeSummaryDTO,
|
||||
IngredientDTO,
|
||||
ProductionStepDTO,
|
||||
CreateRecipeRequest,
|
||||
|
|
@ -21,6 +22,7 @@ export const RECIPE_TYPE_LABELS: Record<RecipeType, string> = {
|
|||
|
||||
export type {
|
||||
RecipeDTO,
|
||||
RecipeSummaryDTO,
|
||||
IngredientDTO,
|
||||
ProductionStepDTO,
|
||||
CreateRecipeRequest,
|
||||
|
|
@ -34,8 +36,10 @@ const BASE = '/api/recipes';
|
|||
|
||||
export function createRecipesResource(client: AxiosInstance) {
|
||||
return {
|
||||
async list(): Promise<RecipeDTO[]> {
|
||||
const res = await client.get<RecipeDTO[]>(BASE);
|
||||
async list(status?: RecipeStatus): Promise<RecipeSummaryDTO[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (status) params['status'] = status;
|
||||
const res = await client.get<RecipeSummaryDTO[]>(BASE, { params });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
|
|
@ -71,6 +75,11 @@ export function createRecipesResource(client: AxiosInstance) {
|
|||
const res = await client.post<RecipeDTO>(`${BASE}/${id}/activate`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async archiveRecipe(id: string): Promise<RecipeDTO> {
|
||||
const res = await client.post<RecipeDTO>(`${BASE}/${id}/archive`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
28
frontend/packages/api-client/src/resources/stocks.ts
Normal file
28
frontend/packages/api-client/src/resources/stocks.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/** Stocks resource – Inventory BC. */
|
||||
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import type { StockBatchDTO, AddStockBatchRequest } from '@effigenix/types';
|
||||
|
||||
export type BatchType = 'PURCHASED' | 'PRODUCED';
|
||||
|
||||
export const BATCH_TYPE_LABELS: Record<BatchType, string> = {
|
||||
PURCHASED: 'Eingekauft',
|
||||
PRODUCED: 'Produziert',
|
||||
};
|
||||
|
||||
export type { StockBatchDTO, AddStockBatchRequest };
|
||||
|
||||
// ── Resource factory ─────────────────────────────────────────────────────────
|
||||
|
||||
const BASE = '/api/inventory/stocks';
|
||||
|
||||
export function createStocksResource(client: AxiosInstance) {
|
||||
return {
|
||||
async addBatch(stockId: string, request: AddStockBatchRequest): Promise<StockBatchDTO> {
|
||||
const res = await client.post<StockBatchDTO>(`${BASE}/${stockId}/batches`, request);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type StocksResource = ReturnType<typeof createStocksResource>;
|
||||
|
|
@ -347,7 +347,7 @@ export interface paths {
|
|||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
get: operations["listRecipes"];
|
||||
put?: never;
|
||||
post: operations["createRecipe"];
|
||||
delete?: never;
|
||||
|
|
@ -388,6 +388,22 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/recipes/{id}/archive": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["archiveRecipe"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/recipes/{id}/activate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -436,6 +452,22 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/inventory/stocks/{stockId}/batches": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["addBatch"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/customers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -708,6 +740,22 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/recipes/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getRecipe"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/{id}/roles/{roleName}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1209,6 +1257,25 @@ export interface components {
|
|||
/** Format: int32 */
|
||||
minimumShelfLifeDays?: number | null;
|
||||
};
|
||||
AddStockBatchRequest: {
|
||||
batchId: string;
|
||||
batchType: string;
|
||||
quantityAmount: string;
|
||||
quantityUnit: string;
|
||||
expiryDate: string;
|
||||
};
|
||||
StockBatchResponse: {
|
||||
id?: string;
|
||||
batchId?: string;
|
||||
batchType?: string;
|
||||
quantityAmount?: number;
|
||||
quantityUnit?: string;
|
||||
/** Format: date */
|
||||
expiryDate?: string;
|
||||
status?: string;
|
||||
/** Format: date-time */
|
||||
receivedAt?: string;
|
||||
};
|
||||
CreateCustomerRequest: {
|
||||
name: string;
|
||||
/** @enum {string} */
|
||||
|
|
@ -1303,6 +1370,29 @@ export interface components {
|
|||
priceModel: "FIXED" | "WEIGHT_BASED";
|
||||
price: number;
|
||||
};
|
||||
RecipeSummaryResponse: {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Format: int32 */
|
||||
version: number;
|
||||
type: string;
|
||||
description: string;
|
||||
/** Format: int32 */
|
||||
yieldPercentage: number;
|
||||
/** Format: int32 */
|
||||
shelfLifeDays?: number | null;
|
||||
outputQuantity: string;
|
||||
outputUom: string;
|
||||
status: string;
|
||||
/** Format: int32 */
|
||||
ingredientCount: number;
|
||||
/** Format: int32 */
|
||||
stepCount: number;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
RemoveCertificateRequest: {
|
||||
certificateType: string;
|
||||
issuer?: string;
|
||||
|
|
@ -2156,6 +2246,28 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
listRecipes: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["RecipeSummaryResponse"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createRecipe: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -2232,6 +2344,28 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
archiveRecipe: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["RecipeResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
activateRecipe: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -2325,6 +2459,32 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
addBatch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
stockId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AddStockBatchRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["StockBatchResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listCustomers: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
@ -2811,6 +2971,28 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
getRecipe: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["RecipeResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
removeRole: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import type { components } from './generated/api';
|
|||
// Response DTOs
|
||||
export type StorageLocationDTO = components['schemas']['StorageLocationResponse'];
|
||||
export type TemperatureRangeDTO = components['schemas']['TemperatureRangeResponse'];
|
||||
export type StockBatchDTO = components['schemas']['StockBatchResponse'];
|
||||
|
||||
// Request types
|
||||
export type CreateStorageLocationRequest = components['schemas']['CreateStorageLocationRequest'];
|
||||
export type UpdateStorageLocationRequest = components['schemas']['UpdateStorageLocationRequest'];
|
||||
export type AddStockBatchRequest = components['schemas']['AddStockBatchRequest'];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { components } from './generated/api';
|
|||
|
||||
// Response DTOs
|
||||
export type RecipeDTO = components['schemas']['RecipeResponse'];
|
||||
export type RecipeSummaryDTO = components['schemas']['RecipeSummaryResponse'];
|
||||
export type IngredientDTO = components['schemas']['IngredientResponse'];
|
||||
export type ProductionStepDTO = components['schemas']['ProductionStepResponse'];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue