mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:19:35 +01:00
feat(inventory): Tui Inventur anlegen, starten, zählen, abschließen
This commit is contained in:
parent
ae95a0284f
commit
85a3f634fd
11 changed files with 804 additions and 1 deletions
|
|
@ -61,6 +61,9 @@ import { StockListScreen } from './components/inventory/StockListScreen.js';
|
||||||
import { StockDetailScreen } from './components/inventory/StockDetailScreen.js';
|
import { StockDetailScreen } from './components/inventory/StockDetailScreen.js';
|
||||||
import { StockCreateScreen } from './components/inventory/StockCreateScreen.js';
|
import { StockCreateScreen } from './components/inventory/StockCreateScreen.js';
|
||||||
import { ReserveStockScreen } from './components/inventory/ReserveStockScreen.js';
|
import { ReserveStockScreen } from './components/inventory/ReserveStockScreen.js';
|
||||||
|
import { InventoryCountListScreen } from './components/inventory/InventoryCountListScreen.js';
|
||||||
|
import { InventoryCountCreateScreen } from './components/inventory/InventoryCountCreateScreen.js';
|
||||||
|
import { InventoryCountDetailScreen } from './components/inventory/InventoryCountDetailScreen.js';
|
||||||
|
|
||||||
function ScreenRouter() {
|
function ScreenRouter() {
|
||||||
const { isAuthenticated, loading } = useAuth();
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|
@ -132,6 +135,9 @@ function ScreenRouter() {
|
||||||
{current === 'stock-movement-list' && <StockMovementListScreen />}
|
{current === 'stock-movement-list' && <StockMovementListScreen />}
|
||||||
{current === 'stock-movement-detail' && <StockMovementDetailScreen />}
|
{current === 'stock-movement-detail' && <StockMovementDetailScreen />}
|
||||||
{current === 'stock-movement-record' && <StockMovementRecordScreen />}
|
{current === 'stock-movement-record' && <StockMovementRecordScreen />}
|
||||||
|
{current === 'inventory-count-list' && <InventoryCountListScreen />}
|
||||||
|
{current === 'inventory-count-create' && <InventoryCountCreateScreen />}
|
||||||
|
{current === 'inventory-count-detail' && <InventoryCountDetailScreen />}
|
||||||
{/* Produktion */}
|
{/* Produktion */}
|
||||||
{current === 'production-menu' && <ProductionMenu />}
|
{current === 'production-menu' && <ProductionMenu />}
|
||||||
{current === 'recipe-list' && <RecipeListScreen />}
|
{current === 'recipe-list' && <RecipeListScreen />}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { useNavigation } from '../../state/navigation-context.js';
|
||||||
|
import { useInventoryCounts } from '../../hooks/useInventoryCounts.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';
|
||||||
|
|
||||||
|
type Field = 'storageLocationId' | 'countDate';
|
||||||
|
const FIELDS: Field[] = ['storageLocationId', 'countDate'];
|
||||||
|
|
||||||
|
function todayISO(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryCountCreateScreen() {
|
||||||
|
const { replace, back } = useNavigation();
|
||||||
|
const { createInventoryCount, loading, error, clearError } = useInventoryCounts();
|
||||||
|
const { storageLocations, fetchStorageLocations } = useStorageLocations();
|
||||||
|
|
||||||
|
const [locationIdx, setLocationIdx] = useState(0);
|
||||||
|
const [countDate, setCountDate] = useState(todayISO());
|
||||||
|
const [activeField, setActiveField] = useState<Field>('storageLocationId');
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchStorageLocations();
|
||||||
|
}, [fetchStorageLocations]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const errors: Partial<Record<Field, string>> = {};
|
||||||
|
if (storageLocations.length === 0) errors.storageLocationId = 'Kein Lagerort verfügbar.';
|
||||||
|
if (!countDate.trim()) errors.countDate = 'Datum muss angegeben werden.';
|
||||||
|
setFieldErrors(errors);
|
||||||
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
|
const location = storageLocations[locationIdx]!;
|
||||||
|
const result = await createInventoryCount({
|
||||||
|
storageLocationId: location.id,
|
||||||
|
countDate: countDate.trim(),
|
||||||
|
});
|
||||||
|
if (result) replace('inventory-count-detail', { inventoryCountId: result.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
useInput((_input, key) => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (activeField === 'storageLocationId') {
|
||||||
|
if (key.upArrow) {
|
||||||
|
if (locationIdx > 0) setLocationIdx((i) => i - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
if (locationIdx < storageLocations.length - 1) setLocationIdx((i) => i + 1);
|
||||||
|
else setActiveField('countDate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.return || key.tab) {
|
||||||
|
setActiveField('countDate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeField === 'countDate') {
|
||||||
|
if (key.escape) back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" alignItems="center" paddingY={2}>
|
||||||
|
<LoadingSpinner label="Inventur wird angelegt..." />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Text color="cyan" bold>Neue Inventur</Text>
|
||||||
|
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||||
|
|
||||||
|
<Box flexDirection="column" gap={1} width={60}>
|
||||||
|
{/* Storage location selector */}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={activeField === 'storageLocationId' ? 'cyan' : 'gray'}>Lagerort * (↑↓ auswählen)</Text>
|
||||||
|
{fieldErrors.storageLocationId && <Text color="red">{fieldErrors.storageLocationId}</Text>}
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor={activeField === 'storageLocationId' ? 'cyan' : 'gray'} paddingX={1}>
|
||||||
|
{storageLocations.length === 0 ? (
|
||||||
|
<Text color="gray" dimColor>Keine Lagerorte verfügbar.</Text>
|
||||||
|
) : (
|
||||||
|
storageLocations.slice(Math.max(0, locationIdx - 3), locationIdx + 4).map((loc, i) => {
|
||||||
|
const actualIdx = Math.max(0, locationIdx - 3) + i;
|
||||||
|
const isSelected = actualIdx === locationIdx;
|
||||||
|
return (
|
||||||
|
<Text key={loc.id} color={isSelected ? 'cyan' : 'white'}>
|
||||||
|
{isSelected ? '▶ ' : ' '}{loc.name}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Count date */}
|
||||||
|
<FormInput
|
||||||
|
label="Datum * (YYYY-MM-DD)"
|
||||||
|
value={countDate}
|
||||||
|
onChange={setCountDate}
|
||||||
|
onSubmit={() => void handleSubmit()}
|
||||||
|
focus={activeField === 'countDate'}
|
||||||
|
error={fieldErrors.countDate}
|
||||||
|
placeholder="2026-03-18"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="gray" dimColor>
|
||||||
|
↑↓/Tab Feld wechseln · Enter bestätigen/speichern · Escape Abbrechen
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
import { useNavigation } from '../../state/navigation-context.js';
|
||||||
|
import { useAuth } from '../../state/auth-context.js';
|
||||||
|
import { useInventoryCounts } from '../../hooks/useInventoryCounts.js';
|
||||||
|
import { useStockNameLookup } from '../../hooks/useStockNameLookup.js';
|
||||||
|
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||||
|
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||||
|
import { SuccessDisplay } from '../shared/SuccessDisplay.js';
|
||||||
|
import { INVENTORY_COUNT_STATUS_LABELS } from '@effigenix/api-client';
|
||||||
|
import type { InventoryCountStatus, CountItemDTO } from '@effigenix/api-client';
|
||||||
|
|
||||||
|
type Mode = 'view' | 'menu' | 'record-item' | 'record-amount' | 'confirm-start' | 'confirm-complete';
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<InventoryCountStatus, string> = {
|
||||||
|
OPEN: 'yellow',
|
||||||
|
COUNTING: 'blue',
|
||||||
|
COMPLETED: 'green',
|
||||||
|
CANCELLED: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISIBLE_ITEMS = 7;
|
||||||
|
|
||||||
|
export function InventoryCountDetailScreen() {
|
||||||
|
const { params, back } = useNavigation();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const {
|
||||||
|
inventoryCount,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchInventoryCount,
|
||||||
|
startInventoryCount,
|
||||||
|
recordCountItem,
|
||||||
|
completeInventoryCount,
|
||||||
|
clearError,
|
||||||
|
} = useInventoryCounts();
|
||||||
|
const { articleName, locationName } = useStockNameLookup();
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<Mode>('view');
|
||||||
|
const [menuIndex, setMenuIndex] = useState(0);
|
||||||
|
const [itemIndex, setItemIndex] = useState(0);
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const countId = params.inventoryCountId ?? '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countId) void fetchInventoryCount(countId);
|
||||||
|
}, [fetchInventoryCount, countId]);
|
||||||
|
|
||||||
|
const items: CountItemDTO[] = inventoryCount?.countItems ?? [];
|
||||||
|
const selectedItem = items[itemIndex];
|
||||||
|
const countedCount = items.filter((i) => i.actualQuantityAmount !== null).length;
|
||||||
|
const allCounted = items.length > 0 && countedCount === items.length;
|
||||||
|
const currentUser = user?.username ?? '';
|
||||||
|
const isDifferentUser = inventoryCount?.initiatedBy !== currentUser;
|
||||||
|
|
||||||
|
const getMenuItems = () => {
|
||||||
|
if (!inventoryCount) return [];
|
||||||
|
const actions: { label: string; action: string }[] = [];
|
||||||
|
if (inventoryCount.status === 'OPEN') {
|
||||||
|
actions.push({ label: 'Zählung starten', action: 'start' });
|
||||||
|
}
|
||||||
|
if (inventoryCount.status === 'COUNTING') {
|
||||||
|
actions.push({ label: 'Position erfassen', action: 'record' });
|
||||||
|
if (allCounted && isDifferentUser) {
|
||||||
|
actions.push({ label: 'Inventur abschließen', action: 'complete' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = getMenuItems();
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
const result = await startInventoryCount(countId);
|
||||||
|
if (result) {
|
||||||
|
setSuccess('Zählung gestartet.');
|
||||||
|
setMode('view');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecordItem = async () => {
|
||||||
|
if (!selectedItem || !amount.trim()) return;
|
||||||
|
const result = await recordCountItem(countId, selectedItem.id, {
|
||||||
|
actualQuantityAmount: amount.trim(),
|
||||||
|
actualQuantityUnit: selectedItem.expectedQuantityUnit,
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
setSuccess(`${articleName(selectedItem.articleId)} erfasst.`);
|
||||||
|
setAmount('');
|
||||||
|
// advance to next uncounted item or stay
|
||||||
|
const updated = result.countItems ?? [];
|
||||||
|
const nextUncounted = updated.findIndex((i, idx) => idx > itemIndex && i.actualQuantityAmount === null);
|
||||||
|
if (nextUncounted >= 0) {
|
||||||
|
setItemIndex(nextUncounted);
|
||||||
|
}
|
||||||
|
setMode('record-item');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
const result = await completeInventoryCount(countId);
|
||||||
|
if (result) {
|
||||||
|
setSuccess('Inventur abgeschlossen.');
|
||||||
|
setMode('view');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (mode === 'record-amount') {
|
||||||
|
if (key.escape) setMode('record-item');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'confirm-start') {
|
||||||
|
if (input.toLowerCase() === 'j') void handleStart();
|
||||||
|
if (input.toLowerCase() === 'n' || key.escape) setMode('view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'confirm-complete') {
|
||||||
|
if (input.toLowerCase() === 'j') void handleComplete();
|
||||||
|
if (input.toLowerCase() === 'n' || key.escape) setMode('view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'record-item') {
|
||||||
|
if (key.upArrow) setItemIndex((i) => Math.max(0, i - 1));
|
||||||
|
if (key.downArrow) setItemIndex((i) => Math.min(items.length - 1, i + 1));
|
||||||
|
if (key.return && selectedItem) {
|
||||||
|
setAmount(selectedItem.actualQuantityAmount ?? '');
|
||||||
|
setMode('record-amount');
|
||||||
|
}
|
||||||
|
if (key.escape) setMode('view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'menu') {
|
||||||
|
if (key.upArrow) setMenuIndex((i) => Math.max(0, i - 1));
|
||||||
|
if (key.downArrow) setMenuIndex((i) => Math.min(menuItems.length - 1, i + 1));
|
||||||
|
if (key.return && menuItems[menuIndex]) {
|
||||||
|
const action = menuItems[menuIndex].action;
|
||||||
|
if (action === 'start') setMode('confirm-start');
|
||||||
|
else if (action === 'record') { setMode('record-item'); setItemIndex(0); }
|
||||||
|
else if (action === 'complete') setMode('confirm-complete');
|
||||||
|
}
|
||||||
|
if (key.escape) setMode('view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// view mode
|
||||||
|
if (input === 'm' && menuItems.length > 0) { setMode('menu'); setMenuIndex(0); }
|
||||||
|
if (input === 'r') void fetchInventoryCount(countId);
|
||||||
|
if (key.backspace || key.escape) back();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading && !inventoryCount) return <LoadingSpinner label="Lade Inventur..." />;
|
||||||
|
|
||||||
|
if (!inventoryCount) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||||
|
<Text color="red">Inventur nicht gefunden.</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = inventoryCount.status as InventoryCountStatus;
|
||||||
|
const statusColor = STATUS_COLORS[status] ?? 'white';
|
||||||
|
const statusLabel = INVENTORY_COUNT_STATUS_LABELS[status] ?? status;
|
||||||
|
|
||||||
|
// progress bar
|
||||||
|
const progressPct = items.length > 0 ? Math.round((countedCount / items.length) * 100) : 0;
|
||||||
|
const barWidth = 20;
|
||||||
|
const filled = Math.round((progressPct / 100) * barWidth);
|
||||||
|
const progressBar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||||
|
|
||||||
|
// scrolling window for items
|
||||||
|
const halfWindow = Math.floor(VISIBLE_ITEMS / 2);
|
||||||
|
let windowStart = Math.max(0, itemIndex - halfWindow);
|
||||||
|
const windowEnd = Math.min(items.length, windowStart + VISIBLE_ITEMS);
|
||||||
|
if (windowEnd - windowStart < VISIBLE_ITEMS && items.length >= VISIBLE_ITEMS) {
|
||||||
|
windowStart = Math.max(0, windowEnd - VISIBLE_ITEMS);
|
||||||
|
}
|
||||||
|
const visibleItems = items.slice(windowStart, windowEnd);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box gap={2}>
|
||||||
|
<Text color="cyan" bold>Inventur</Text>
|
||||||
|
{loading && <Text color="gray"> (aktualisiere...)</Text>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||||
|
{success && <SuccessDisplay message={success} onDismiss={() => setSuccess(null)} />}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||||
|
<Box><Text color="gray">Lagerort: </Text><Text>{locationName(inventoryCount.storageLocationId)}</Text></Box>
|
||||||
|
<Box><Text color="gray">Datum: </Text><Text>{inventoryCount.countDate}</Text></Box>
|
||||||
|
<Box><Text color="gray">Status: </Text><Text color={statusColor} bold>{statusLabel}</Text></Box>
|
||||||
|
<Box><Text color="gray">Erstellt von: </Text><Text>{inventoryCount.initiatedBy}</Text></Box>
|
||||||
|
{inventoryCount.completedBy && (
|
||||||
|
<Box><Text color="gray">Abgeschl. von: </Text><Text>{inventoryCount.completedBy}</Text></Box>
|
||||||
|
)}
|
||||||
|
<Box>
|
||||||
|
<Text color="gray">Fortschritt: </Text>
|
||||||
|
<Text color={allCounted ? 'green' : 'yellow'}>{progressBar} {countedCount}/{items.length} ({progressPct}%)</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Count Items Table */}
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||||
|
<Text color="cyan" bold>Positionen ({items.length})</Text>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Text color="gray" dimColor>Keine Positionen vorhanden.</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box paddingX={1}>
|
||||||
|
<Text color="gray" bold>{' Artikel'.padEnd(26)}</Text>
|
||||||
|
<Text color="gray" bold>{'Soll'.padEnd(14)}</Text>
|
||||||
|
<Text color="gray" bold>{'Ist'.padEnd(14)}</Text>
|
||||||
|
<Text color="gray" bold>Abweichung</Text>
|
||||||
|
</Box>
|
||||||
|
{visibleItems.map((item, i) => {
|
||||||
|
const actualIdx = windowStart + i;
|
||||||
|
const isSelected = mode === 'record-item' && actualIdx === itemIndex;
|
||||||
|
const isCounted = item.actualQuantityAmount !== null;
|
||||||
|
const hasDeviation = isCounted && item.deviation !== null && item.deviation !== '0' && item.deviation !== '0.0';
|
||||||
|
const rowColor = isSelected ? 'cyan' : isCounted ? (hasDeviation ? 'yellow' : 'green') : 'gray';
|
||||||
|
const name = articleName(item.articleId);
|
||||||
|
const expected = `${item.expectedQuantityAmount} ${item.expectedQuantityUnit}`;
|
||||||
|
const actual = isCounted ? `${item.actualQuantityAmount} ${item.actualQuantityUnit}` : '---';
|
||||||
|
const deviation = isCounted && item.deviation !== null ? item.deviation : '---';
|
||||||
|
return (
|
||||||
|
<Box key={item.id} paddingX={1}>
|
||||||
|
<Text color={rowColor}>{isSelected ? '▶ ' : ' '}</Text>
|
||||||
|
<Text color={rowColor}>{name.substring(0, 22).padEnd(23)}</Text>
|
||||||
|
<Text color={isSelected ? 'cyan' : 'gray'}>{expected.padEnd(14)}</Text>
|
||||||
|
<Text color={isSelected ? 'cyan' : (isCounted ? rowColor : 'gray')}>{actual.padEnd(14)}</Text>
|
||||||
|
<Text color={isSelected ? 'cyan' : (hasDeviation ? 'yellow' : 'gray')}>{deviation}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{items.length > VISIBLE_ITEMS && (
|
||||||
|
<Text color="gray" dimColor> … {items.length - VISIBLE_ITEMS} weitere Positionen (scrollen mit ↑↓)</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Menu */}
|
||||||
|
{mode === 'menu' && (
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||||
|
<Text color="cyan" bold>Aktionen</Text>
|
||||||
|
{menuItems.map((item, i) => (
|
||||||
|
<Text key={item.action} color={i === menuIndex ? 'cyan' : 'white'}>
|
||||||
|
{i === menuIndex ? '▶ ' : ' '}{item.label}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text color="gray" dimColor>↑↓ nav · Enter ausführen · Escape zurück</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Record Item mode */}
|
||||||
|
{mode === 'record-item' && (
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||||
|
<Text color="cyan" bold>Position erfassen</Text>
|
||||||
|
<Text color="gray" dimColor>↑↓ Position wählen · Enter Ist-Menge eingeben · Escape zurück</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Record Amount */}
|
||||||
|
{mode === 'record-amount' && selectedItem && (
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||||
|
<Text color="yellow" bold>Ist-Menge für: {articleName(selectedItem.articleId)}</Text>
|
||||||
|
<Text color="gray">Soll: {selectedItem.expectedQuantityAmount} {selectedItem.expectedQuantityUnit}</Text>
|
||||||
|
<Box>
|
||||||
|
<Text color="gray"> › </Text>
|
||||||
|
<TextInput
|
||||||
|
value={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
onSubmit={() => void handleRecordItem()}
|
||||||
|
focus={true}
|
||||||
|
/>
|
||||||
|
<Text color="gray"> {selectedItem.expectedQuantityUnit}</Text>
|
||||||
|
</Box>
|
||||||
|
<Text color="gray" dimColor>Enter bestätigen · Escape abbrechen</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Start */}
|
||||||
|
{mode === 'confirm-start' && (
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||||
|
<Text color="yellow" bold>Zählung starten?</Text>
|
||||||
|
<Text>Die Inventur wird in den Status "In Zählung" versetzt.</Text>
|
||||||
|
<Text color="gray" dimColor>[J] Ja · [N] Nein</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Complete */}
|
||||||
|
{mode === 'confirm-complete' && (
|
||||||
|
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||||
|
<Text color="yellow" bold>Inventur abschließen?</Text>
|
||||||
|
<Text>Alle {items.length} Positionen wurden gezählt. Ausgleichsbuchungen werden erstellt.</Text>
|
||||||
|
<Text color="gray" dimColor>[J] Ja · [N] Nein</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Four-eyes hint */}
|
||||||
|
{inventoryCount.status === 'COUNTING' && allCounted && !isDifferentUser && (
|
||||||
|
<Box>
|
||||||
|
<Text color="yellow">⚠ Vier-Augen-Prinzip: Abschluss nur durch einen anderen Benutzer möglich.</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="gray" dimColor>
|
||||||
|
{menuItems.length > 0 ? '[m] Aktionsmenü · ' : ''}[r] Refresh · Backspace Zurück
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { useNavigation } from '../../state/navigation-context.js';
|
||||||
|
import { useInventoryCounts } from '../../hooks/useInventoryCounts.js';
|
||||||
|
import { useStockNameLookup } from '../../hooks/useStockNameLookup.js';
|
||||||
|
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||||
|
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||||
|
import { INVENTORY_COUNT_STATUS_LABELS } from '@effigenix/api-client';
|
||||||
|
import type { InventoryCountStatus } from '@effigenix/api-client';
|
||||||
|
|
||||||
|
type StatusFilter = 'ALL' | InventoryCountStatus;
|
||||||
|
const FILTER_CYCLE: StatusFilter[] = ['ALL', 'OPEN', 'COUNTING', 'COMPLETED', 'CANCELLED'];
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<InventoryCountStatus, string> = {
|
||||||
|
OPEN: 'yellow',
|
||||||
|
COUNTING: 'blue',
|
||||||
|
COMPLETED: 'green',
|
||||||
|
CANCELLED: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InventoryCountListScreen() {
|
||||||
|
const { navigate, back } = useNavigation();
|
||||||
|
const { inventoryCounts, loading, error, fetchInventoryCounts, clearError } = useInventoryCounts();
|
||||||
|
const { locationName } = useStockNameLookup();
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchInventoryCounts();
|
||||||
|
}, [fetchInventoryCounts]);
|
||||||
|
|
||||||
|
const filtered = statusFilter === 'ALL'
|
||||||
|
? inventoryCounts
|
||||||
|
: inventoryCounts.filter((c) => c.status === statusFilter);
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
|
||||||
|
if (key.downArrow) setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1));
|
||||||
|
|
||||||
|
if (key.return && filtered.length > 0) {
|
||||||
|
const item = filtered[selectedIndex];
|
||||||
|
if (item) navigate('inventory-count-detail', { inventoryCountId: item.id });
|
||||||
|
}
|
||||||
|
if (input === 'n') navigate('inventory-count-create');
|
||||||
|
if (input === 'r') void fetchInventoryCounts();
|
||||||
|
if (input === 'f') {
|
||||||
|
setStatusFilter((current) => {
|
||||||
|
const idx = FILTER_CYCLE.indexOf(current);
|
||||||
|
return FILTER_CYCLE[(idx + 1) % FILTER_CYCLE.length]!;
|
||||||
|
});
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
if (key.backspace || key.escape) back();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterLabel = statusFilter === 'ALL' ? 'Alle' : INVENTORY_COUNT_STATUS_LABELS[statusFilter];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box gap={2}>
|
||||||
|
<Text color="cyan" bold>Inventuren</Text>
|
||||||
|
<Text color="gray" dimColor>
|
||||||
|
Status: <Text color="yellow">{filterLabel}</Text>
|
||||||
|
{' '}({filtered.length})
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loading && <LoadingSpinner label="Lade Inventuren..." />}
|
||||||
|
{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'.padEnd(18)}</Text>
|
||||||
|
<Text color="gray" bold>{'Lagerort'.padEnd(22)}</Text>
|
||||||
|
<Text color="gray" bold>{'Datum'.padEnd(14)}</Text>
|
||||||
|
<Text color="gray" bold>{'Fortschritt'.padEnd(14)}</Text>
|
||||||
|
<Text color="gray" bold>Erstellt von</Text>
|
||||||
|
</Box>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<Box paddingX={1} paddingY={1}>
|
||||||
|
<Text color="gray" dimColor>Keine Inventuren gefunden.</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{filtered.map((count, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
const textColor = isSelected ? 'cyan' : 'white';
|
||||||
|
const statusColor = STATUS_COLORS[count.status] ?? 'white';
|
||||||
|
const statusLabel = INVENTORY_COUNT_STATUS_LABELS[count.status] ?? count.status;
|
||||||
|
const counted = count.countItems.filter((i) => i.actualQuantityAmount !== null).length;
|
||||||
|
const total = count.countItems.length;
|
||||||
|
const progress = `${counted}/${total}`;
|
||||||
|
return (
|
||||||
|
<Box key={count.id} paddingX={1}>
|
||||||
|
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
|
||||||
|
<Text color={statusColor}>{statusLabel.padEnd(15)}</Text>
|
||||||
|
<Text color={textColor}>{locationName(count.storageLocationId).substring(0, 20).padEnd(21)}</Text>
|
||||||
|
<Text color={isSelected ? 'cyan' : 'gray'}>{count.countDate.padEnd(14)}</Text>
|
||||||
|
<Text color={isSelected ? 'cyan' : 'gray'}>{progress.padEnd(14)}</Text>
|
||||||
|
<Text color={isSelected ? 'cyan' : 'gray'}>{count.initiatedBy}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="gray" dimColor>
|
||||||
|
↑↓ nav · Enter Details · [n] Neu · [f] Filter · [r] Refresh · Backspace Zurück
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ const MENU_ITEMS: MenuItem[] = [
|
||||||
{ label: 'Bestände', screen: 'stock-list', description: 'Bestände einsehen, anlegen und Chargen verwalten' },
|
{ label: 'Bestände', screen: 'stock-list', description: 'Bestände einsehen, anlegen und Chargen verwalten' },
|
||||||
{ label: 'Bestandsbewegungen', screen: 'stock-movement-list', description: 'Wareneingänge, Verbräuche, Korrekturen und Umlagerungen' },
|
{ label: 'Bestandsbewegungen', screen: 'stock-movement-list', description: 'Wareneingänge, Verbräuche, Korrekturen und Umlagerungen' },
|
||||||
{ label: 'Charge einbuchen', screen: 'stock-batch-entry', description: 'Neue Charge in einen Bestand einbuchen' },
|
{ label: 'Charge einbuchen', screen: 'stock-batch-entry', description: 'Neue Charge in einen Bestand einbuchen' },
|
||||||
|
{ label: 'Inventuren', screen: 'inventory-count-list', description: 'Inventuren anlegen, zählen und abschließen' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function InventoryMenu() {
|
export function InventoryMenu() {
|
||||||
|
|
|
||||||
111
frontend/apps/cli/src/hooks/useInventoryCounts.ts
Normal file
111
frontend/apps/cli/src/hooks/useInventoryCounts.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type {
|
||||||
|
InventoryCountDTO,
|
||||||
|
CreateInventoryCountRequest,
|
||||||
|
RecordCountItemRequest,
|
||||||
|
InventoryCountFilter,
|
||||||
|
} from '@effigenix/api-client';
|
||||||
|
import { client } from '../utils/api-client.js';
|
||||||
|
|
||||||
|
interface InventoryCountsState {
|
||||||
|
inventoryCounts: InventoryCountDTO[];
|
||||||
|
inventoryCount: InventoryCountDTO | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInventoryCounts() {
|
||||||
|
const [state, setState] = useState<InventoryCountsState>({
|
||||||
|
inventoryCounts: [],
|
||||||
|
inventoryCount: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchInventoryCounts = useCallback(async (filter?: InventoryCountFilter) => {
|
||||||
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const inventoryCounts = await client.inventoryCounts.list(filter);
|
||||||
|
setState((s) => ({ ...s, inventoryCounts, loading: false, error: null }));
|
||||||
|
} catch (err) {
|
||||||
|
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchInventoryCount = useCallback(async (id: string) => {
|
||||||
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const inventoryCount = await client.inventoryCounts.getById(id);
|
||||||
|
setState((s) => ({ ...s, inventoryCount, loading: false, error: null }));
|
||||||
|
} catch (err) {
|
||||||
|
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createInventoryCount = useCallback(async (request: CreateInventoryCountRequest) => {
|
||||||
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const inventoryCount = await client.inventoryCounts.create(request);
|
||||||
|
setState((s) => ({ ...s, loading: false, error: null }));
|
||||||
|
return inventoryCount;
|
||||||
|
} catch (err) {
|
||||||
|
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startInventoryCount = useCallback(async (id: string) => {
|
||||||
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const inventoryCount = await client.inventoryCounts.start(id);
|
||||||
|
setState((s) => ({ ...s, inventoryCount, loading: false, error: null }));
|
||||||
|
return inventoryCount;
|
||||||
|
} catch (err) {
|
||||||
|
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const recordCountItem = useCallback(async (countId: string, itemId: string, request: RecordCountItemRequest) => {
|
||||||
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const inventoryCount = await client.inventoryCounts.recordItem(countId, itemId, request);
|
||||||
|
setState((s) => ({ ...s, inventoryCount, loading: false, error: null }));
|
||||||
|
return inventoryCount;
|
||||||
|
} catch (err) {
|
||||||
|
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const completeInventoryCount = useCallback(async (id: string) => {
|
||||||
|
setState((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const inventoryCount = await client.inventoryCounts.complete(id);
|
||||||
|
setState((s) => ({ ...s, inventoryCount, loading: false, error: null }));
|
||||||
|
return inventoryCount;
|
||||||
|
} catch (err) {
|
||||||
|
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState((s) => ({ ...s, error: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
fetchInventoryCounts,
|
||||||
|
fetchInventoryCount,
|
||||||
|
createInventoryCount,
|
||||||
|
startInventoryCount,
|
||||||
|
recordCountItem,
|
||||||
|
completeInventoryCount,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -57,7 +57,11 @@ export type Screen =
|
||||||
| 'production-order-list'
|
| 'production-order-list'
|
||||||
| 'production-order-create'
|
| 'production-order-create'
|
||||||
| 'production-order-detail'
|
| 'production-order-detail'
|
||||||
| 'stock-reserve';
|
| 'stock-reserve'
|
||||||
|
// Inventuren
|
||||||
|
| 'inventory-count-list'
|
||||||
|
| 'inventory-count-create'
|
||||||
|
| 'inventory-count-detail';
|
||||||
|
|
||||||
interface NavigationState {
|
interface NavigationState {
|
||||||
current: Screen;
|
current: Screen;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export { createProductionOrdersResource } from './resources/production-orders.js
|
||||||
export { createStocksResource } from './resources/stocks.js';
|
export { createStocksResource } from './resources/stocks.js';
|
||||||
export { createStockMovementsResource } from './resources/stock-movements.js';
|
export { createStockMovementsResource } from './resources/stock-movements.js';
|
||||||
export { createCountriesResource } from './resources/countries.js';
|
export { createCountriesResource } from './resources/countries.js';
|
||||||
|
export { createInventoryCountsResource } from './resources/inventory-counts.js';
|
||||||
export {
|
export {
|
||||||
ApiError,
|
ApiError,
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
|
|
@ -116,6 +117,11 @@ export type {
|
||||||
StockMovementDTO,
|
StockMovementDTO,
|
||||||
RecordStockMovementRequest,
|
RecordStockMovementRequest,
|
||||||
CountryDTO,
|
CountryDTO,
|
||||||
|
InventoryCountDTO,
|
||||||
|
CountItemDTO,
|
||||||
|
InventoryCountStatus,
|
||||||
|
CreateInventoryCountRequest,
|
||||||
|
RecordCountItemRequest,
|
||||||
} from '@effigenix/types';
|
} from '@effigenix/types';
|
||||||
|
|
||||||
// Resource types (runtime, stay in resource files)
|
// Resource types (runtime, stay in resource files)
|
||||||
|
|
@ -149,6 +155,8 @@ export type { StocksResource, BatchType, StockBatchStatus, StockFilter, Referenc
|
||||||
export type { StockMovementsResource, MovementType, MovementDirection, StockMovementFilter } from './resources/stock-movements.js';
|
export type { StockMovementsResource, MovementType, MovementDirection, StockMovementFilter } from './resources/stock-movements.js';
|
||||||
export { MOVEMENT_TYPE_LABELS, MOVEMENT_DIRECTION_LABELS } from './resources/stock-movements.js';
|
export { MOVEMENT_TYPE_LABELS, MOVEMENT_DIRECTION_LABELS } from './resources/stock-movements.js';
|
||||||
export type { CountriesResource } from './resources/countries.js';
|
export type { CountriesResource } from './resources/countries.js';
|
||||||
|
export type { InventoryCountsResource, InventoryCountFilter } from './resources/inventory-counts.js';
|
||||||
|
export { INVENTORY_COUNT_STATUS_LABELS } from './resources/inventory-counts.js';
|
||||||
export { BATCH_TYPE_LABELS, STOCK_BATCH_STATUS_LABELS, REFERENCE_TYPE_LABELS, RESERVATION_PRIORITY_LABELS } from './resources/stocks.js';
|
export { BATCH_TYPE_LABELS, STOCK_BATCH_STATUS_LABELS, REFERENCE_TYPE_LABELS, RESERVATION_PRIORITY_LABELS } from './resources/stocks.js';
|
||||||
|
|
||||||
import { createApiClient } from './client.js';
|
import { createApiClient } from './client.js';
|
||||||
|
|
@ -166,6 +174,7 @@ import { createProductionOrdersResource } from './resources/production-orders.js
|
||||||
import { createStocksResource } from './resources/stocks.js';
|
import { createStocksResource } from './resources/stocks.js';
|
||||||
import { createStockMovementsResource } from './resources/stock-movements.js';
|
import { createStockMovementsResource } from './resources/stock-movements.js';
|
||||||
import { createCountriesResource } from './resources/countries.js';
|
import { createCountriesResource } from './resources/countries.js';
|
||||||
|
import { createInventoryCountsResource } from './resources/inventory-counts.js';
|
||||||
import type { TokenProvider } from './token-provider.js';
|
import type { TokenProvider } from './token-provider.js';
|
||||||
import type { ApiConfig } from '@effigenix/config';
|
import type { ApiConfig } from '@effigenix/config';
|
||||||
|
|
||||||
|
|
@ -194,6 +203,7 @@ export function createEffigenixClient(
|
||||||
stocks: createStocksResource(axiosClient),
|
stocks: createStocksResource(axiosClient),
|
||||||
stockMovements: createStockMovementsResource(axiosClient),
|
stockMovements: createStockMovementsResource(axiosClient),
|
||||||
countries: createCountriesResource(axiosClient),
|
countries: createCountriesResource(axiosClient),
|
||||||
|
inventoryCounts: createInventoryCountsResource(axiosClient),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/** Inventory Counts resource – Inventory BC. */
|
||||||
|
|
||||||
|
import type { AxiosInstance } from 'axios';
|
||||||
|
import type {
|
||||||
|
InventoryCountDTO,
|
||||||
|
CreateInventoryCountRequest,
|
||||||
|
RecordCountItemRequest,
|
||||||
|
InventoryCountStatus,
|
||||||
|
} from '@effigenix/types';
|
||||||
|
|
||||||
|
export type { InventoryCountDTO, CreateInventoryCountRequest, RecordCountItemRequest, InventoryCountStatus };
|
||||||
|
|
||||||
|
export const INVENTORY_COUNT_STATUS_LABELS: Record<InventoryCountStatus, string> = {
|
||||||
|
OPEN: 'Offen',
|
||||||
|
COUNTING: 'In Zählung',
|
||||||
|
COMPLETED: 'Abgeschlossen',
|
||||||
|
CANCELLED: 'Abgebrochen',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface InventoryCountFilter {
|
||||||
|
storageLocationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = '/api/inventory/inventory-counts';
|
||||||
|
|
||||||
|
export function createInventoryCountsResource(client: AxiosInstance) {
|
||||||
|
return {
|
||||||
|
async list(filter?: InventoryCountFilter): Promise<InventoryCountDTO[]> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (filter?.storageLocationId) params.storageLocationId = filter.storageLocationId;
|
||||||
|
const res = await client.get<InventoryCountDTO[]>(BASE, { params });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: string): Promise<InventoryCountDTO> {
|
||||||
|
const res = await client.get<InventoryCountDTO>(`${BASE}/${id}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(request: CreateInventoryCountRequest): Promise<InventoryCountDTO> {
|
||||||
|
const res = await client.post<InventoryCountDTO>(BASE, request);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async start(id: string): Promise<InventoryCountDTO> {
|
||||||
|
const res = await client.patch<InventoryCountDTO>(`${BASE}/${id}/start`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async recordItem(countId: string, itemId: string, request: RecordCountItemRequest): Promise<InventoryCountDTO> {
|
||||||
|
const res = await client.patch<InventoryCountDTO>(`${BASE}/${countId}/items/${itemId}`, request);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async complete(id: string): Promise<InventoryCountDTO> {
|
||||||
|
const res = await client.post<InventoryCountDTO>(`${BASE}/${id}/complete`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InventoryCountsResource = ReturnType<typeof createInventoryCountsResource>;
|
||||||
|
|
@ -18,6 +18,7 @@ export * from './customer';
|
||||||
export * from './inventory';
|
export * from './inventory';
|
||||||
export * from './production';
|
export * from './production';
|
||||||
export * from './country';
|
export * from './country';
|
||||||
|
export * from './inventory-count';
|
||||||
|
|
||||||
// Re-export generated types for advanced usage
|
// Re-export generated types for advanced usage
|
||||||
export type { components, paths } from './generated/api';
|
export type { components, paths } from './generated/api';
|
||||||
|
|
|
||||||
36
frontend/packages/types/src/inventory-count.ts
Normal file
36
frontend/packages/types/src/inventory-count.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Inventory Count types (manual – not in OpenAPI spec)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type InventoryCountStatus = 'OPEN' | 'COUNTING' | 'COMPLETED' | 'CANCELLED';
|
||||||
|
|
||||||
|
export interface CountItemDTO {
|
||||||
|
id: string;
|
||||||
|
articleId: string;
|
||||||
|
expectedQuantityAmount: string;
|
||||||
|
expectedQuantityUnit: string;
|
||||||
|
actualQuantityAmount: string | null;
|
||||||
|
actualQuantityUnit: string | null;
|
||||||
|
deviation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryCountDTO {
|
||||||
|
id: string;
|
||||||
|
storageLocationId: string;
|
||||||
|
countDate: string;
|
||||||
|
initiatedBy: string;
|
||||||
|
completedBy: string | null;
|
||||||
|
status: InventoryCountStatus;
|
||||||
|
createdAt: string;
|
||||||
|
countItems: CountItemDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInventoryCountRequest {
|
||||||
|
storageLocationId: string;
|
||||||
|
countDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordCountItemRequest {
|
||||||
|
actualQuantityAmount: string;
|
||||||
|
actualQuantityUnit: string;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue