Files
FEDEO/mobile/app/more/inventory.tsx
2026-03-16 20:46:26 +01:00

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)',
},
});