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