erp-core/mobile/app/(tabs)/scanner.tsx
rckrdmrd 0086695b4c
Some checks failed
ERP Core CI / Backend Lint (push) Has been cancelled
ERP Core CI / Backend Unit Tests (push) Has been cancelled
ERP Core CI / Backend Integration Tests (push) Has been cancelled
ERP Core CI / Frontend Lint (push) Has been cancelled
ERP Core CI / Frontend Unit Tests (push) Has been cancelled
ERP Core CI / Frontend E2E Tests (push) Has been cancelled
ERP Core CI / Database DDL Validation (push) Has been cancelled
ERP Core CI / Backend Build (push) Has been cancelled
ERP Core CI / Frontend Build (push) Has been cancelled
ERP Core CI / CI Success (push) Has been cancelled
Performance Tests / Lighthouse CI (push) Has been cancelled
Performance Tests / Bundle Size Analysis (push) Has been cancelled
Performance Tests / k6 Load Tests (push) Has been cancelled
Performance Tests / Performance Summary (push) Has been cancelled
[SIMCO-V38] feat: Actualizar a SIMCO v3.8.0 + cambios backend
- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8
- Actualizaciones en modulos CRM y OpenAPI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:53:05 -06:00

525 lines
14 KiB
TypeScript

/**
* Scanner Screen
*
* Dedicated screen for barcode/QR scanning with product lookup
*/
import { useState, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
ScrollView,
Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { BarcodeScanner } from '@/components/BarcodeScanner';
import { useBarcode, BARCODE_PRESETS } from '@/hooks/useBarcode';
import { productsApi } from '@/services/api';
import { Product, ScannedBarcode, ProductBarcode } from '@/types';
type ScanMode = 'product' | 'qr' | 'all';
interface ScannedProduct {
barcode: string;
product: Product | null;
scannedAt: number;
error?: string;
}
export default function ScannerScreen() {
const [mode, setMode] = useState<ScanMode>('product');
const [isScanning, setIsScanning] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [scannedProducts, setScannedProducts] = useState<ScannedProduct[]>([]);
const [selectedProduct, setSelectedProduct] = useState<ScannedProduct | null>(null);
const handleProductScan = useCallback(async (barcode: ProductBarcode) => {
if (!barcode.isValid) {
Alert.alert('Código Inválido', `El código ${barcode.code} no es válido.`);
return;
}
setIsLoading(true);
try {
// Search product by barcode
const response = await productsApi.list({ search: barcode.code, limit: 1 });
const product = response.data?.[0] || null;
const scannedProduct: ScannedProduct = {
barcode: barcode.code,
product,
scannedAt: Date.now(),
error: product ? undefined : 'Producto no encontrado',
};
setScannedProducts((prev) => [scannedProduct, ...prev.slice(0, 19)]);
setSelectedProduct(scannedProduct);
if (!product) {
Alert.alert(
'Producto No Encontrado',
`No se encontró un producto con el código ${barcode.code}.`,
[
{ text: 'Continuar Escaneando', style: 'cancel' },
{
text: 'Crear Producto',
onPress: () => handleCreateProduct(barcode.code),
},
]
);
}
} catch (error) {
console.error('Error searching product:', error);
const scannedProduct: ScannedProduct = {
barcode: barcode.code,
product: null,
scannedAt: Date.now(),
error: 'Error al buscar producto',
};
setScannedProducts((prev) => [scannedProduct, ...prev.slice(0, 19)]);
} finally {
setIsLoading(false);
}
}, []);
const handleQRScan = useCallback((data: any) => {
Alert.alert('QR Escaneado', JSON.stringify(data, null, 2));
}, []);
const handleCreateProduct = (barcode: string) => {
Alert.alert('Crear Producto', `Crear producto con código: ${barcode}\n\nFuncionalidad próximamente.`);
};
const handleAddToInventory = (product: Product) => {
Alert.alert(
'Agregar a Inventario',
`¿Agregar ${product.name} al inventario?`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Agregar',
onPress: () => {
Alert.alert('Agregado', `${product.name} agregado al inventario.`);
},
},
]
);
};
const handleAddToOrder = (product: Product) => {
Alert.alert(
'Agregar a Pedido',
`¿Agregar ${product.name} al pedido actual?`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Agregar',
onPress: () => {
Alert.alert('Agregado', `${product.name} agregado al pedido.`);
},
},
]
);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(amount);
};
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
});
};
if (isScanning) {
return (
<BarcodeScanner
mode={mode}
showTorch
showHistory
onProductScan={handleProductScan}
onQRScan={handleQRScan}
onClose={() => setIsScanning(false)}
/>
);
}
return (
<View style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Escáner</Text>
<Text style={styles.headerSubtitle}>
{scannedProducts.length} productos escaneados
</Text>
</View>
{/* Mode Selector */}
<View style={styles.modeSelector}>
{(['product', 'qr', 'all'] as ScanMode[]).map((m) => (
<TouchableOpacity
key={m}
style={[styles.modeButton, mode === m && styles.modeButtonActive]}
onPress={() => setMode(m)}
>
<Ionicons
name={
m === 'product'
? 'barcode-outline'
: m === 'qr'
? 'qr-code-outline'
: 'scan-outline'
}
size={20}
color={mode === m ? '#ffffff' : '#64748b'}
/>
<Text style={[styles.modeText, mode === m && styles.modeTextActive]}>
{m === 'product' ? 'Productos' : m === 'qr' ? 'QR' : 'Todos'}
</Text>
</TouchableOpacity>
))}
</View>
{/* Scan Button */}
<TouchableOpacity
style={styles.scanButton}
onPress={() => setIsScanning(true)}
>
<Ionicons name="scan" size={32} color="#ffffff" />
<Text style={styles.scanButtonText}>Iniciar Escaneo</Text>
</TouchableOpacity>
{/* Loading Indicator */}
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#1e40af" />
<Text style={styles.loadingText}>Buscando producto...</Text>
</View>
)}
{/* Selected Product Details */}
{selectedProduct && selectedProduct.product && (
<View style={styles.productDetails}>
<View style={styles.productHeader}>
<View style={styles.productIcon}>
<Ionicons
name={
selectedProduct.product.productType === 'service'
? 'construct-outline'
: 'cube-outline'
}
size={28}
color="#1e40af"
/>
</View>
<View style={styles.productInfo}>
<Text style={styles.productCode}>{selectedProduct.product.code}</Text>
<Text style={styles.productName}>{selectedProduct.product.name}</Text>
{selectedProduct.product.categoryName && (
<Text style={styles.productCategory}>
{selectedProduct.product.categoryName}
</Text>
)}
</View>
<View style={styles.productPrice}>
<Text style={styles.priceAmount}>
{formatCurrency(selectedProduct.product.unitPrice)}
</Text>
<Text style={styles.priceLabel}>Precio</Text>
</View>
</View>
<View style={styles.productActions}>
<TouchableOpacity
style={styles.actionButton}
onPress={() => handleAddToInventory(selectedProduct.product!)}
>
<Ionicons name="archive-outline" size={20} color="#1e40af" />
<Text style={styles.actionButtonText}>Inventario</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.actionButtonPrimary]}
onPress={() => handleAddToOrder(selectedProduct.product!)}
>
<Ionicons name="cart-outline" size={20} color="#ffffff" />
<Text style={[styles.actionButtonText, styles.actionButtonTextPrimary]}>
Agregar
</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* Scan History */}
<View style={styles.historySection}>
<Text style={styles.historyTitle}>Historial de Escaneos</Text>
<ScrollView style={styles.historyList}>
{scannedProducts.length === 0 ? (
<View style={styles.emptyHistory}>
<Ionicons name="scan-outline" size={48} color="#d1d5db" />
<Text style={styles.emptyHistoryText}>
No hay productos escaneados
</Text>
</View>
) : (
scannedProducts.map((item, index) => (
<TouchableOpacity
key={`${item.barcode}-${item.scannedAt}`}
style={[
styles.historyItem,
selectedProduct?.barcode === item.barcode &&
selectedProduct?.scannedAt === item.scannedAt &&
styles.historyItemSelected,
]}
onPress={() => setSelectedProduct(item)}
>
<View style={styles.historyItemIcon}>
<Ionicons
name={item.product ? 'checkmark-circle' : 'alert-circle'}
size={24}
color={item.product ? '#059669' : '#dc2626'}
/>
</View>
<View style={styles.historyItemContent}>
<Text style={styles.historyItemCode}>{item.barcode}</Text>
<Text style={styles.historyItemName}>
{item.product?.name || item.error || 'Producto no encontrado'}
</Text>
</View>
<Text style={styles.historyItemTime}>
{formatTime(item.scannedAt)}
</Text>
</TouchableOpacity>
))
)}
</ScrollView>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
header: {
padding: 20,
paddingTop: 60,
backgroundColor: '#1e40af',
},
headerTitle: {
fontSize: 28,
fontWeight: '700',
color: '#ffffff',
},
headerSubtitle: {
fontSize: 14,
color: '#93c5fd',
marginTop: 4,
},
modeSelector: {
flexDirection: 'row',
padding: 16,
gap: 8,
},
modeButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 10,
backgroundColor: '#ffffff',
gap: 6,
},
modeButtonActive: {
backgroundColor: '#1e40af',
},
modeText: {
fontSize: 14,
fontWeight: '500',
color: '#64748b',
},
modeTextActive: {
color: '#ffffff',
},
scanButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 16,
paddingVertical: 20,
borderRadius: 12,
backgroundColor: '#1e40af',
gap: 12,
},
scanButtonText: {
fontSize: 18,
fontWeight: '600',
color: '#ffffff',
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 100,
},
loadingText: {
fontSize: 16,
color: '#64748b',
marginTop: 12,
},
productDetails: {
margin: 16,
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
},
productHeader: {
flexDirection: 'row',
alignItems: 'center',
},
productIcon: {
width: 56,
height: 56,
borderRadius: 12,
backgroundColor: '#eff6ff',
justifyContent: 'center',
alignItems: 'center',
},
productInfo: {
flex: 1,
marginLeft: 12,
},
productCode: {
fontSize: 12,
color: '#64748b',
fontWeight: '500',
},
productName: {
fontSize: 16,
fontWeight: '600',
color: '#1f2937',
marginTop: 2,
},
productCategory: {
fontSize: 13,
color: '#9ca3af',
marginTop: 2,
},
productPrice: {
alignItems: 'flex-end',
},
priceAmount: {
fontSize: 18,
fontWeight: '700',
color: '#1e40af',
},
priceLabel: {
fontSize: 12,
color: '#9ca3af',
},
productActions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: '#f1f5f9',
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 8,
backgroundColor: '#eff6ff',
gap: 6,
},
actionButtonPrimary: {
backgroundColor: '#1e40af',
},
actionButtonText: {
fontSize: 14,
fontWeight: '500',
color: '#1e40af',
},
actionButtonTextPrimary: {
color: '#ffffff',
},
historySection: {
flex: 1,
marginTop: 16,
},
historyTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1f2937',
paddingHorizontal: 16,
marginBottom: 12,
},
historyList: {
flex: 1,
},
emptyHistory: {
alignItems: 'center',
paddingVertical: 48,
},
emptyHistoryText: {
fontSize: 14,
color: '#9ca3af',
marginTop: 12,
},
historyItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#ffffff',
marginHorizontal: 16,
marginBottom: 8,
padding: 12,
borderRadius: 10,
},
historyItemSelected: {
backgroundColor: '#eff6ff',
borderWidth: 1,
borderColor: '#1e40af',
},
historyItemIcon: {
marginRight: 12,
},
historyItemContent: {
flex: 1,
},
historyItemCode: {
fontSize: 14,
fontWeight: '600',
color: '#1f2937',
},
historyItemName: {
fontSize: 13,
color: '#64748b',
marginTop: 2,
},
historyItemTime: {
fontSize: 12,
color: '#9ca3af',
},
});