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

feat(tui): neue Backend-Features anbinden und Status-Werte korrigieren

TUI-Anbindung für Reservierung bestätigen (US-4.3), Produktionsauftrag
umterminieren und filtern (US-P17). Status-Werte CREATED→PLANNED und
IN_PRODUCTION→IN_PROGRESS korrigiert. Fehlenden GET /{id} Endpoint für
Produktionsaufträge im Backend ergänzt.
This commit is contained in:
Sebastian Frick 2026-02-25 23:36:42 +01:00
parent 8a84bf5f25
commit 417f8fcdae
14 changed files with 467 additions and 27 deletions

View file

@ -10,7 +10,7 @@ import { SuccessDisplay } from '../shared/SuccessDisplay.js';
import { STOCK_BATCH_STATUS_LABELS, REFERENCE_TYPE_LABELS, RESERVATION_PRIORITY_LABELS } from '@effigenix/api-client';
import type { StockBatchStatus, ReferenceType, ReservationPriority, ReservationDTO, StockBatchDTO } from '@effigenix/api-client';
type Mode = 'view' | 'menu' | 'batch-actions' | 'block-reason' | 'remove-amount' | 'remove-unit' | 'reservation-actions' | 'confirm-release-reservation';
type Mode = 'view' | 'menu' | 'batch-actions' | 'block-reason' | 'remove-amount' | 'remove-unit' | 'reservation-actions' | 'confirm-release-reservation' | 'confirm-reservation-actions' | 'confirm-confirm-reservation';
const BATCH_STATUS_COLORS: Record<string, string> = {
AVAILABLE: 'green',
@ -21,7 +21,7 @@ const BATCH_STATUS_COLORS: Record<string, string> = {
export function StockDetailScreen() {
const { params, back, navigate } = useNavigation();
const { stock, loading, error, fetchStock, blockBatch, unblockBatch, removeBatch, releaseReservation, clearError } = useStocks();
const { stock, loading, error, fetchStock, blockBatch, unblockBatch, removeBatch, releaseReservation, confirmReservation, clearError } = useStocks();
const { articleName, locationName } = useStockNameLookup();
const [mode, setMode] = useState<Mode>('view');
const [menuIndex, setMenuIndex] = useState(0);
@ -31,6 +31,7 @@ export function StockDetailScreen() {
const [removeAmount, setRemoveAmount] = useState('');
const [removeUnit, setRemoveUnit] = useState('');
const [reservationIndex, setReservationIndex] = useState(0);
const [confirmResIndex, setConfirmResIndex] = useState(0);
const [success, setSuccess] = useState<string | null>(null);
const stockId = params.stockId ?? '';
@ -43,6 +44,7 @@ export function StockDetailScreen() {
const selectedBatch = batches[batchIndex];
const reservations: ReservationDTO[] = (stock as { reservations?: ReservationDTO[] })?.reservations ?? [];
const selectedReservation = reservations[reservationIndex];
const selectedConfirmReservation = reservations[confirmResIndex];
const getBatchActions = () => {
if (!selectedBatch) return [];
@ -119,10 +121,21 @@ export function StockDetailScreen() {
}
};
const handleConfirmReservation = async () => {
if (!selectedConfirmReservation?.id) return;
const result = await confirmReservation(stockId, selectedConfirmReservation.id);
if (result) {
setSuccess('Reservierung bestätigt Material entnommen.');
setMode('view');
setConfirmResIndex(0);
}
};
const MENU_ITEMS = [
{ label: 'Chargen verwalten', action: 'batches' },
{ label: 'Bestand reservieren', action: 'reserve' },
...(reservations.length > 0 ? [{ label: 'Reservierung freigeben', action: 'release-reservation' }] : []),
...(reservations.length > 0 ? [{ label: 'Reservierung bestätigen', action: 'confirm-reservation' }] : []),
];
useInput((input, key) => {
@ -143,6 +156,24 @@ export function StockDetailScreen() {
return;
}
if (mode === 'confirm-confirm-reservation') {
if (input.toLowerCase() === 'j') {
void handleConfirmReservation();
}
if (input.toLowerCase() === 'n' || key.escape) setMode('confirm-reservation-actions');
return;
}
if (mode === 'confirm-reservation-actions') {
if (key.upArrow) setConfirmResIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setConfirmResIndex((i) => Math.min(reservations.length - 1, i + 1));
if (key.return && reservations[confirmResIndex]) {
setMode('confirm-confirm-reservation');
}
if (key.escape) setMode('view');
return;
}
if (mode === 'confirm-release-reservation') {
if (input.toLowerCase() === 'j') {
void handleReleaseReservation();
@ -187,6 +218,9 @@ export function StockDetailScreen() {
} else if (action === 'release-reservation') {
setMode('reservation-actions');
setReservationIndex(0);
} else if (action === 'confirm-reservation') {
setMode('confirm-reservation-actions');
setConfirmResIndex(0);
}
}
if (key.escape) setMode('view');
@ -281,7 +315,7 @@ export function StockDetailScreen() {
<Text color="gray" bold>Reserviert am</Text>
</Box>
{reservations.map((r, i) => {
const isSelected = mode === 'reservation-actions' && i === reservationIndex;
const isSelected = (mode === 'reservation-actions' && i === reservationIndex) || (mode === 'confirm-reservation-actions' && i === confirmResIndex);
const textColor = isSelected ? 'cyan' : 'white';
const refLabel = REFERENCE_TYPE_LABELS[(r.referenceType ?? '') as ReferenceType] ?? r.referenceType;
const prioLabel = RESERVATION_PRIORITY_LABELS[(r.priority ?? '') as ReservationPriority] ?? r.priority;
@ -393,6 +427,22 @@ export function StockDetailScreen() {
</Box>
)}
{mode === 'confirm-reservation-actions' && (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text color="cyan" bold>Reservierung zum Bestätigen auswählen</Text>
<Text color="gray" dimColor> Reservierung wählen · Enter bestätigen · Escape zurück</Text>
</Box>
)}
{mode === 'confirm-confirm-reservation' && selectedConfirmReservation && (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>Reservierung bestätigen Material entnehmen?</Text>
<Text>Referenz: {selectedConfirmReservation.referenceType} / {selectedConfirmReservation.referenceId}</Text>
<Text>Menge: {selectedConfirmReservation.quantityAmount} {selectedConfirmReservation.quantityUnit}</Text>
<Text color="gray" dimColor>[J] Ja · [N] Nein</Text>
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
[m] Aktionsmenü · Backspace Zurück

View file

@ -10,24 +10,25 @@ import { PRODUCTION_ORDER_STATUS_LABELS, PRIORITY_LABELS } from '@effigenix/api-
import type { ProductionOrderStatus, Priority } from '@effigenix/api-client';
const STATUS_COLORS: Record<string, string> = {
CREATED: 'gray',
PLANNED: 'gray',
RELEASED: 'yellow',
IN_PRODUCTION: 'blue',
IN_PROGRESS: 'blue',
COMPLETED: 'green',
CANCELLED: 'red',
};
type Mode = 'view' | 'menu' | 'start-batch-input';
type Mode = 'view' | 'menu' | 'start-batch-input' | 'reschedule-input';
export function ProductionOrderDetailScreen() {
const { params, back } = useNavigation();
const {
productionOrder, loading, error,
fetchProductionOrder, releaseProductionOrder, startProductionOrder, clearError,
fetchProductionOrder, releaseProductionOrder, rescheduleProductionOrder, startProductionOrder, clearError,
} = useProductionOrders();
const [mode, setMode] = useState<Mode>('view');
const [menuIndex, setMenuIndex] = useState(0);
const [batchId, setBatchId] = useState('');
const [newDate, setNewDate] = useState('');
const [success, setSuccess] = useState<string | null>(null);
const orderId = params.orderId ?? '';
@ -39,11 +40,13 @@ export function ProductionOrderDetailScreen() {
const getMenuItems = () => {
const items: { label: string; action: string }[] = [];
const status = productionOrder?.status;
if (status === 'CREATED') {
if (status === 'PLANNED') {
items.push({ label: 'Freigeben', action: 'release' });
items.push({ label: 'Umterminieren', action: 'reschedule' });
}
if (status === 'RELEASED') {
items.push({ label: 'Produktion starten', action: 'start' });
items.push({ label: 'Umterminieren', action: 'reschedule' });
}
return items;
};
@ -68,9 +71,24 @@ export function ProductionOrderDetailScreen() {
}
};
const handleReschedule = async () => {
if (!newDate.trim()) return;
const result = await rescheduleProductionOrder(orderId, newDate.trim());
if (result) {
setSuccess(`Umterminiert auf ${newDate.trim()}.`);
setMode('view');
setNewDate('');
}
};
useInput((_input, key) => {
if (loading) return;
if (mode === 'reschedule-input') {
if (key.escape) setMode('menu');
return;
}
if (mode === 'start-batch-input') {
if (key.escape) setMode('menu');
return;
@ -82,6 +100,10 @@ export function ProductionOrderDetailScreen() {
if (key.return && menuItems[menuIndex]) {
const action = menuItems[menuIndex].action;
if (action === 'release') void handleRelease();
if (action === 'reschedule') {
setMode('reschedule-input');
setNewDate('');
}
if (action === 'start') {
setMode('start-batch-input');
setBatchId('');
@ -175,6 +197,22 @@ export function ProductionOrderDetailScreen() {
</Box>
)}
{mode === 'reschedule-input' && (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>Neues Datum (YYYY-MM-DD):</Text>
<Box>
<Text color="gray"> </Text>
<TextInput
value={newDate}
onChange={setNewDate}
onSubmit={() => void handleReschedule()}
focus={true}
/>
</Box>
<Text color="gray" dimColor>Enter bestätigen · Escape abbrechen</Text>
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
{menuItems.length > 0 ? '[m] Aktionsmenü · ' : ''}Backspace Zurück

View file

@ -1,16 +1,25 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useProductionOrders } from '../../hooks/useProductionOrders.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { PRODUCTION_ORDER_STATUS_LABELS, PRIORITY_LABELS } from '@effigenix/api-client';
import type { ProductionOrderStatus, Priority } from '@effigenix/api-client';
import type { ProductionOrderStatus, Priority, ProductionOrderFilter } from '@effigenix/api-client';
const STATUS_FILTER_KEYS: Record<string, { status?: ProductionOrderStatus; label: string }> = {
a: { label: 'Alle' },
p: { status: 'PLANNED', label: 'Geplant' },
f: { status: 'RELEASED', label: 'Freigegeben' },
i: { status: 'IN_PROGRESS', label: 'In Produktion' },
c: { status: 'COMPLETED', label: 'Abgeschlossen' },
x: { status: 'CANCELLED', label: 'Storniert' },
};
const STATUS_COLORS: Record<string, string> = {
CREATED: 'gray',
PLANNED: 'gray',
RELEASED: 'yellow',
IN_PRODUCTION: 'blue',
IN_PROGRESS: 'blue',
COMPLETED: 'green',
CANCELLED: 'red',
};
@ -19,6 +28,14 @@ export function ProductionOrderListScreen() {
const { navigate, back } = useNavigation();
const { productionOrders, loading, error, fetchProductionOrders, clearError } = useProductionOrders();
const [selectedIndex, setSelectedIndex] = useState(0);
const [activeFilter, setActiveFilter] = useState<{ status?: ProductionOrderStatus; label: string }>({ label: 'Alle' });
const loadWithFilter = useCallback((filter: { status?: ProductionOrderStatus; label: string }) => {
setActiveFilter(filter);
setSelectedIndex(0);
const f: ProductionOrderFilter | undefined = filter.status ? { status: filter.status } : undefined;
void fetchProductionOrders(f);
}, [fetchProductionOrders]);
useEffect(() => {
void fetchProductionOrders();
@ -35,7 +52,11 @@ export function ProductionOrderListScreen() {
if (order?.id) navigate('production-order-detail', { orderId: order.id });
}
if (input === 'n') navigate('production-order-create');
if (input === 'r') void fetchProductionOrders();
if (input === 'r') loadWithFilter(activeFilter);
const filterDef = STATUS_FILTER_KEYS[input];
if (filterDef) loadWithFilter(filterDef);
if (key.backspace || key.escape) back();
});
@ -44,6 +65,7 @@ export function ProductionOrderListScreen() {
<Box gap={2}>
<Text color="cyan" bold>Produktionsaufträge</Text>
<Text color="gray" dimColor>({productionOrders.length})</Text>
{activeFilter.status && <Text color="yellow">[{activeFilter.label}]</Text>}
</Box>
{loading && <LoadingSpinner label="Lade Aufträge..." />}
@ -86,7 +108,7 @@ export function ProductionOrderListScreen() {
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · [r] Aktualisieren · Backspace Zurück
nav · Enter Details · [n] Neu · [r] Aktualisieren · [a]lle [p]lan [f]rei [i]n Prod [c]omp [x]storno · Bksp Zurück
</Text>
</Box>
</Box>

View file

@ -1,5 +1,5 @@
import { useState, useCallback } from 'react';
import type { ProductionOrderDTO, CreateProductionOrderRequest, StartProductionOrderRequest } from '@effigenix/api-client';
import type { ProductionOrderDTO, CreateProductionOrderRequest, StartProductionOrderRequest, ProductionOrderFilter } from '@effigenix/api-client';
import { client } from '../utils/api-client.js';
interface ProductionOrdersState {
@ -21,10 +21,10 @@ export function useProductionOrders() {
error: null,
});
const fetchProductionOrders = useCallback(async () => {
const fetchProductionOrders = useCallback(async (filter?: ProductionOrderFilter) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const productionOrders = await client.productionOrders.list();
const productionOrders = await client.productionOrders.list(filter);
setState((s) => ({ ...s, productionOrders, loading: false, error: null }));
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
@ -65,6 +65,18 @@ export function useProductionOrders() {
}
}, []);
const rescheduleProductionOrder = useCallback(async (id: string, newPlannedDate: string) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const productionOrder = await client.productionOrders.reschedule(id, { newPlannedDate });
setState((s) => ({ ...s, productionOrder, loading: false, error: null }));
return productionOrder;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
return null;
}
}, []);
const startProductionOrder = useCallback(async (id: string, request: StartProductionOrderRequest) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
@ -87,6 +99,7 @@ export function useProductionOrders() {
fetchProductionOrder,
createProductionOrder,
releaseProductionOrder,
rescheduleProductionOrder,
startProductionOrder,
clearError,
};

View file

@ -137,6 +137,19 @@ export function useStocks() {
}
}, []);
const confirmReservation = useCallback(async (stockId: string, reservationId: string) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
await client.stocks.confirmReservation(stockId, reservationId);
const stock = await client.stocks.getById(stockId);
setState((s) => ({ ...s, stock, loading: false, error: null }));
return true;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
return false;
}
}, []);
const releaseReservation = useCallback(async (stockId: string, reservationId: string) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
@ -166,6 +179,7 @@ export function useStocks() {
unblockBatch,
reserveStock,
releaseReservation,
confirmReservation,
clearError,
};
}