542 lines
15 KiB
TypeScript
542 lines
15 KiB
TypeScript
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)',
|
|
},
|
|
});
|