Fixes
This commit is contained in:
541
mobile/app/more/inventory.tsx
Normal file
541
mobile/app/more/inventory.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { BarcodeScanningResult, BarcodeType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||
|
||||
import { Customer, CustomerInventoryItem, fetchAllCustomerInventoryItems, fetchCustomers, renderPrintLabel } from '@/src/lib/api';
|
||||
import { getActiveNimbotConnection, printNimbotEncodedLabel } from '@/src/lib/nimbot';
|
||||
import { useAuth } from '@/src/providers/auth-provider';
|
||||
|
||||
const PRIMARY = '#69c350';
|
||||
const SCAN_TYPES: BarcodeType[] = [
|
||||
'datamatrix',
|
||||
'code128',
|
||||
'code39',
|
||||
'code93',
|
||||
'ean13',
|
||||
'ean8',
|
||||
'upc_a',
|
||||
'upc_e',
|
||||
'itf14',
|
||||
'qr',
|
||||
];
|
||||
|
||||
function resolveCustomerId(item: CustomerInventoryItem): number | null {
|
||||
const raw = item.customer;
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') {
|
||||
return raw.id ? Number(raw.id) : null;
|
||||
}
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
function resolveCustomerName(item: CustomerInventoryItem, customersById: Map<number, Customer>): string {
|
||||
const raw = item.customer;
|
||||
if (raw && typeof raw === 'object' && raw.name) {
|
||||
return String(raw.name);
|
||||
}
|
||||
|
||||
const id = resolveCustomerId(item);
|
||||
if (!id) return '-';
|
||||
return String(customersById.get(id)?.name || `#${id}`);
|
||||
}
|
||||
|
||||
export default function InventoryScreen() {
|
||||
const { token } = useAuth();
|
||||
const params = useLocalSearchParams<{ action?: string | string[] }>();
|
||||
|
||||
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
|
||||
const scanTriggeredRef = useRef(false);
|
||||
|
||||
const [items, setItems] = useState<CustomerInventoryItem[]>([]);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [printingItemId, setPrintingItemId] = useState<number | null>(null);
|
||||
const [printInfo, setPrintInfo] = useState<string | null>(null);
|
||||
const [printError, setPrintError] = useState<string | null>(null);
|
||||
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const [hasScanned, setHasScanned] = useState(false);
|
||||
|
||||
const incomingAction = useMemo(() => {
|
||||
const raw = Array.isArray(params.action) ? params.action[0] : params.action;
|
||||
return String(raw || '').toLowerCase();
|
||||
}, [params.action]);
|
||||
|
||||
const customersById = useMemo(() => {
|
||||
const map = new Map<number, Customer>();
|
||||
(customers || []).forEach((customer) => {
|
||||
if (Number.isFinite(Number(customer.id))) {
|
||||
map.set(Number(customer.id), customer);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [customers]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
return (items || [])
|
||||
.filter((item) => {
|
||||
if (!showArchived && item.archived) return false;
|
||||
if (terms.length === 0) return true;
|
||||
|
||||
const customerName = resolveCustomerName(item, customersById);
|
||||
const haystack = [
|
||||
item.name,
|
||||
item.customerInventoryId,
|
||||
item.serialNumber,
|
||||
item.description,
|
||||
item.manufacturer,
|
||||
item.manufacturerNumber,
|
||||
customerName,
|
||||
]
|
||||
.map((value) => String(value || '').toLowerCase())
|
||||
.join(' ');
|
||||
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
})
|
||||
.sort((a, b) => Number(b.id) - Number(a.id));
|
||||
}, [customersById, items, search, showArchived]);
|
||||
|
||||
const load = useCallback(
|
||||
async (showSpinner = true) => {
|
||||
if (!token) return;
|
||||
if (showSpinner) setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [inventoryRows, customerRows] = await Promise.all([
|
||||
fetchAllCustomerInventoryItems(token, true),
|
||||
fetchCustomers(token, true),
|
||||
]);
|
||||
setItems(inventoryRows || []);
|
||||
setCustomers(customerRows || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Kundeninventar konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
},
|
||||
[token]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
void load(true);
|
||||
}, [load, token]);
|
||||
|
||||
const openScanner = useCallback(async () => {
|
||||
const granted = cameraPermission?.granted;
|
||||
if (!granted) {
|
||||
const request = await requestCameraPermission();
|
||||
if (!request.granted) {
|
||||
setError('Bitte erlaube den Zugriff auf die Kamera zum Scannen.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHasScanned(false);
|
||||
setScannerOpen(true);
|
||||
}, [cameraPermission?.granted, requestCameraPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (incomingAction !== 'scan') return;
|
||||
if (scanTriggeredRef.current) return;
|
||||
scanTriggeredRef.current = true;
|
||||
void openScanner();
|
||||
}, [incomingAction, openScanner]);
|
||||
|
||||
async function onRefresh() {
|
||||
setRefreshing(true);
|
||||
await load(false);
|
||||
}
|
||||
|
||||
function closeScanner() {
|
||||
setScannerOpen(false);
|
||||
setHasScanned(false);
|
||||
}
|
||||
|
||||
function onBarcodeScanned(result: BarcodeScanningResult) {
|
||||
if (hasScanned) return;
|
||||
const code = String(result.data || '').trim();
|
||||
if (!code) return;
|
||||
|
||||
setHasScanned(true);
|
||||
setSearch(code);
|
||||
closeScanner();
|
||||
}
|
||||
|
||||
function openNimbotForItem(item: CustomerInventoryItem, customerName: string) {
|
||||
router.push({
|
||||
pathname: '/more/nimbot',
|
||||
params: {
|
||||
itemName: item.name || '',
|
||||
itemId: item.customerInventoryId || '',
|
||||
serial: item.serialNumber || '',
|
||||
customerInventoryId: item.customerInventoryId || '',
|
||||
serialNumber: item.serialNumber || '',
|
||||
customerName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function printItemLabel(item: CustomerInventoryItem, customerName: string) {
|
||||
setPrintError(null);
|
||||
setPrintInfo(null);
|
||||
|
||||
const connected = getActiveNimbotConnection();
|
||||
if (!connected) {
|
||||
openNimbotForItem(item, customerName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setPrintError('Nicht angemeldet. Bitte erneut einloggen.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPrintingItemId(Number(item.id));
|
||||
|
||||
try {
|
||||
const context: Record<string, unknown> = {
|
||||
id: item.id,
|
||||
customerInventoryId: item.customerInventoryId || null,
|
||||
name: item.name || null,
|
||||
customerName: customerName === '-' ? null : customerName,
|
||||
serialNumber: item.serialNumber || null,
|
||||
};
|
||||
|
||||
const rendered = await renderPrintLabel(token, context, 584, 354);
|
||||
await printNimbotEncodedLabel(rendered.encoded, { density: 5, copies: 1, labelType: 1 });
|
||||
|
||||
setPrintInfo(`Label gedruckt: ${item.name || item.customerInventoryId || `#${item.id}`}`);
|
||||
} catch (err) {
|
||||
setPrintError(err instanceof Error ? err.message : 'Label konnte nicht gedruckt werden.');
|
||||
} finally {
|
||||
setPrintingItemId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const isPrinterConnected = Boolean(getActiveNimbotConnection()?.device.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.searchWrap}>
|
||||
<TextInput
|
||||
placeholder="Kundeninventar suchen"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.searchInput}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
/>
|
||||
<View style={styles.searchActions}>
|
||||
<Pressable style={styles.scanButton} onPress={openScanner}>
|
||||
<Text style={styles.scanButtonText}>Scannen</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.toggleButton, showArchived ? styles.toggleButtonActive : null]}
|
||||
onPress={() => setShowArchived((prev) => !prev)}>
|
||||
<Text style={[styles.toggleButtonText, showArchived ? styles.toggleButtonTextActive : null]}>
|
||||
Abgeschlossene anzeigen
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
{printError ? <Text style={styles.error}>{printError}</Text> : null}
|
||||
{printInfo ? <Text style={styles.info}>{printInfo}</Text> : null}
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loadingBox}>
|
||||
<ActivityIndicator />
|
||||
<Text style={styles.loadingText}>Kundeninventar wird geladen...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!loading && filteredItems.length === 0 ? <Text style={styles.empty}>Kein Kundeninventar gefunden.</Text> : null}
|
||||
|
||||
{!loading &&
|
||||
filteredItems.map((item) => {
|
||||
const customerId = resolveCustomerId(item);
|
||||
const customerName = resolveCustomerName(item, customersById);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={String(item.id)}
|
||||
style={({ pressed }) => [styles.row, pressed ? styles.rowPressed : null]}
|
||||
onPress={() => {
|
||||
if (customerId) {
|
||||
router.push(`/more/customer/${customerId}`);
|
||||
}
|
||||
}}>
|
||||
<View style={styles.rowHeader}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{item.name || '-'}
|
||||
</Text>
|
||||
{item.archived ? <Text style={styles.badge}>Abgeschlossen</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.rowSubtitle} numberOfLines={1}>
|
||||
Kunde: {customerName}
|
||||
</Text>
|
||||
<Text style={styles.rowSubtitle} numberOfLines={1}>
|
||||
ID: {item.customerInventoryId || '-'} · Seriennummer: {item.serialNumber || '-'}
|
||||
</Text>
|
||||
<View style={styles.rowActions}>
|
||||
{(() => {
|
||||
const isPrinting = printingItemId === Number(item.id);
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.printButton, isPrinting ? styles.printButtonDisabled : null]}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
void printItemLabel(item, customerName);
|
||||
}}
|
||||
disabled={isPrinting}>
|
||||
<Text style={styles.printButtonText}>
|
||||
{isPrinting ? 'Drucke...' : isPrinterConnected ? 'Label drucken' : 'Mit Nimbot verbinden'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<Modal visible={scannerOpen} animationType="slide" onRequestClose={closeScanner}>
|
||||
<View style={styles.scannerScreen}>
|
||||
<View style={styles.scannerHeader}>
|
||||
<Text style={styles.scannerTitle}>Kundeninventar scannen</Text>
|
||||
<Pressable style={styles.scannerCloseButton} onPress={closeScanner}>
|
||||
<Text style={styles.scannerCloseButtonText}>Schließen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<CameraView
|
||||
style={styles.scannerCamera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: SCAN_TYPES }}
|
||||
onBarcodeScanned={onBarcodeScanned}
|
||||
/>
|
||||
|
||||
<Text style={styles.scannerHint}>Barcode oder DataMatrix mittig ins Bild halten.</Text>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: '#ffffff' },
|
||||
searchWrap: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
backgroundColor: '#ffffff',
|
||||
gap: 8,
|
||||
},
|
||||
searchInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
searchActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
scanButton: {
|
||||
minHeight: 36,
|
||||
borderRadius: 9,
|
||||
backgroundColor: PRIMARY,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scanButtonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '700',
|
||||
fontSize: 13,
|
||||
},
|
||||
toggleButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
toggleButtonActive: {
|
||||
borderColor: PRIMARY,
|
||||
backgroundColor: '#eff9ea',
|
||||
},
|
||||
toggleButtonText: {
|
||||
color: '#374151',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
toggleButtonTextActive: {
|
||||
color: '#3d7a30',
|
||||
},
|
||||
list: { flex: 1, backgroundColor: '#ffffff' },
|
||||
listContent: { paddingBottom: 96 },
|
||||
row: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
backgroundColor: '#ffffff',
|
||||
gap: 2,
|
||||
},
|
||||
rowPressed: { backgroundColor: '#f3f4f6' },
|
||||
rowHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
rowTitle: {
|
||||
color: '#111827',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rowSubtitle: {
|
||||
color: '#6b7280',
|
||||
fontSize: 13,
|
||||
},
|
||||
rowActions: {
|
||||
marginTop: 8,
|
||||
},
|
||||
printButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: PRIMARY,
|
||||
backgroundColor: '#eff9ea',
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
printButtonText: {
|
||||
color: '#3d7a30',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
badge: {
|
||||
color: '#3d7a30',
|
||||
backgroundColor: '#eff9ea',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
fontSize: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
empty: {
|
||||
color: '#6b7280',
|
||||
textAlign: 'center',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
loadingBox: {
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
loadingText: {
|
||||
color: '#6b7280',
|
||||
},
|
||||
error: {
|
||||
color: '#dc2626',
|
||||
fontSize: 13,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 10,
|
||||
},
|
||||
info: {
|
||||
color: '#166534',
|
||||
fontSize: 13,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 10,
|
||||
},
|
||||
printButtonDisabled: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
scannerScreen: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000000',
|
||||
},
|
||||
scannerHeader: {
|
||||
paddingTop: 56,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
scannerTitle: {
|
||||
color: '#ffffff',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
scannerCloseButton: {
|
||||
minHeight: 34,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ffffff',
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scannerCloseButtonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
scannerCamera: {
|
||||
flex: 1,
|
||||
},
|
||||
scannerHint: {
|
||||
color: '#ffffff',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: 'rgba(17,24,39,0.85)',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user