michangarrito-mobile-v2/src/screens/ProductsScreen.tsx
rckrdmrd a71600df2f Migración desde michangarrito/apps/mobile - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:26:57 -06:00

805 lines
23 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
RefreshControl,
Alert,
Modal,
ScrollView,
KeyboardAvoidingView,
Platform,
Image,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, fontSize, borderRadius } from '../constants/theme';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
import { Product, Category } from '../types';
interface ProductFormData {
name: string;
description: string;
sku: string;
barcode: string;
price: string;
cost: string;
currentStock: string;
minStock: string;
categoryId: string;
}
const initialFormData: ProductFormData = {
name: '',
description: '',
sku: '',
barcode: '',
price: '',
cost: '',
currentStock: '0',
minStock: '5',
categoryId: '',
};
export default function ProductsScreen({ navigation }: any) {
const { user } = useAuth();
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [formData, setFormData] = useState<ProductFormData>(initialFormData);
const [saving, setSaving] = useState(false);
const fetchProducts = useCallback(async () => {
try {
const params: any = {};
if (searchQuery) params.search = searchQuery;
if (selectedCategory) params.categoryId = selectedCategory;
const response = await api.get('/products', { params });
setProducts(response.data.data || response.data);
} catch (error) {
console.error('Error fetching products:', error);
}
}, [searchQuery, selectedCategory]);
const fetchCategories = async () => {
try {
const response = await api.get('/categories');
setCategories(response.data.data || response.data);
} catch (error) {
console.error('Error fetching categories:', error);
}
};
useEffect(() => {
const loadData = async () => {
setLoading(true);
await Promise.all([fetchProducts(), fetchCategories()]);
setLoading(false);
};
loadData();
}, []);
useEffect(() => {
const delayedSearch = setTimeout(() => {
fetchProducts();
}, 300);
return () => clearTimeout(delayedSearch);
}, [searchQuery, selectedCategory]);
const onRefresh = async () => {
setRefreshing(true);
await fetchProducts();
setRefreshing(false);
};
const openCreateModal = () => {
setEditingProduct(null);
setFormData(initialFormData);
setModalVisible(true);
};
const openEditModal = (product: Product) => {
setEditingProduct(product);
setFormData({
name: product.name,
description: product.description || '',
sku: product.sku || '',
barcode: product.barcode || '',
price: product.price.toString(),
cost: product.cost?.toString() || '',
currentStock: product.currentStock?.toString() || '0',
minStock: product.minStock?.toString() || '5',
categoryId: product.categoryId || '',
});
setModalVisible(true);
};
const handleSave = async () => {
if (!formData.name.trim()) {
Alert.alert('Error', 'El nombre del producto es requerido');
return;
}
if (!formData.price || parseFloat(formData.price) <= 0) {
Alert.alert('Error', 'El precio debe ser mayor a 0');
return;
}
setSaving(true);
try {
const payload = {
name: formData.name.trim(),
description: formData.description.trim() || null,
sku: formData.sku.trim() || null,
barcode: formData.barcode.trim() || null,
price: parseFloat(formData.price),
cost: formData.cost ? parseFloat(formData.cost) : null,
currentStock: parseInt(formData.currentStock) || 0,
minStock: parseInt(formData.minStock) || 5,
categoryId: formData.categoryId || null,
};
if (editingProduct) {
await api.put(`/products/${editingProduct.id}`, payload);
Alert.alert('Exito', 'Producto actualizado correctamente');
} else {
await api.post('/products', payload);
Alert.alert('Exito', 'Producto creado correctamente');
}
setModalVisible(false);
fetchProducts();
} catch (error: any) {
Alert.alert('Error', error.response?.data?.message || 'No se pudo guardar el producto');
} finally {
setSaving(false);
}
};
const handleDelete = (product: Product) => {
Alert.alert(
'Eliminar Producto',
`¿Estas seguro de eliminar "${product.name}"?`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Eliminar',
style: 'destructive',
onPress: async () => {
try {
await api.delete(`/products/${product.id}`);
fetchProducts();
} catch (error: any) {
Alert.alert('Error', 'No se pudo eliminar el producto');
}
},
},
]
);
};
const toggleFavorite = async (product: Product) => {
try {
await api.patch(`/products/${product.id}/toggle-favorite`);
fetchProducts();
} catch (error) {
console.error('Error toggling favorite:', error);
}
};
const renderProduct = ({ item }: { item: Product }) => {
const isLowStock = item.currentStock <= (item.minStock || 5);
return (
<TouchableOpacity
style={styles.productCard}
onPress={() => openEditModal(item)}
onLongPress={() => handleDelete(item)}
>
<View style={styles.productImageContainer}>
{item.imageUrl ? (
<Image source={{ uri: item.imageUrl }} style={styles.productImage} />
) : (
<View style={styles.productImagePlaceholder}>
<Text style={styles.productImageText}>
{item.name.charAt(0).toUpperCase()}
</Text>
</View>
)}
{item.isFavorite && (
<View style={styles.favoriteIndicator}>
<Text></Text>
</View>
)}
</View>
<View style={styles.productInfo}>
<Text style={styles.productName} numberOfLines={2}>{item.name}</Text>
<Text style={styles.productSku}>{item.sku || 'Sin SKU'}</Text>
<View style={styles.productFooter}>
<Text style={styles.productPrice}>${item.price.toFixed(2)}</Text>
<View style={[
styles.stockBadge,
isLowStock ? styles.stockBadgeLow : styles.stockBadgeOk
]}>
<Text style={[
styles.stockText,
isLowStock ? styles.stockTextLow : styles.stockTextOk
]}>
{item.currentStock} uds
</Text>
</View>
</View>
</View>
<TouchableOpacity
style={styles.favoriteButton}
onPress={() => toggleFavorite(item)}
>
<Text style={{ fontSize: 20 }}>{item.isFavorite ? '★' : '☆'}</Text>
</TouchableOpacity>
</TouchableOpacity>
);
};
const renderCategoryFilter = () => (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.categoryFilter}
contentContainerStyle={styles.categoryFilterContent}
>
<TouchableOpacity
style={[
styles.categoryChip,
!selectedCategory && styles.categoryChipActive
]}
onPress={() => setSelectedCategory(null)}
>
<Text style={[
styles.categoryChipText,
!selectedCategory && styles.categoryChipTextActive
]}>
Todos
</Text>
</TouchableOpacity>
{categories.map((cat) => (
<TouchableOpacity
key={cat.id}
style={[
styles.categoryChip,
selectedCategory === cat.id && styles.categoryChipActive
]}
onPress={() => setSelectedCategory(cat.id)}
>
<Text style={[
styles.categoryChipText,
selectedCategory === cat.id && styles.categoryChipTextActive
]}>
{cat.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
);
const renderFormModal = () => (
<Modal
visible={modalVisible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setModalVisible(false)}
>
<SafeAreaView style={styles.modalContainer}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setModalVisible(false)}>
<Text style={styles.modalCancel}>Cancelar</Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>
{editingProduct ? 'Editar Producto' : 'Nuevo Producto'}
</Text>
<TouchableOpacity onPress={handleSave} disabled={saving}>
<Text style={[styles.modalSave, saving && styles.modalSaveDisabled]}>
{saving ? 'Guardando...' : 'Guardar'}
</Text>
</TouchableOpacity>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView style={styles.formContainer}>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Nombre *</Text>
<TextInput
style={styles.formInput}
value={formData.name}
onChangeText={(text) => setFormData({ ...formData, name: text })}
placeholder="Nombre del producto"
placeholderTextColor={colors.textMuted}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Descripcion</Text>
<TextInput
style={[styles.formInput, styles.formTextArea]}
value={formData.description}
onChangeText={(text) => setFormData({ ...formData, description: text })}
placeholder="Descripcion del producto"
placeholderTextColor={colors.textMuted}
multiline
numberOfLines={3}
/>
</View>
<View style={styles.formRow}>
<View style={[styles.formGroup, { flex: 1, marginRight: spacing.sm }]}>
<Text style={styles.formLabel}>SKU</Text>
<TextInput
style={styles.formInput}
value={formData.sku}
onChangeText={(text) => setFormData({ ...formData, sku: text })}
placeholder="SKU-001"
placeholderTextColor={colors.textMuted}
/>
</View>
<View style={[styles.formGroup, { flex: 1 }]}>
<Text style={styles.formLabel}>Codigo de Barras</Text>
<TextInput
style={styles.formInput}
value={formData.barcode}
onChangeText={(text) => setFormData({ ...formData, barcode: text })}
placeholder="7501234567890"
placeholderTextColor={colors.textMuted}
keyboardType="numeric"
/>
</View>
</View>
<View style={styles.formRow}>
<View style={[styles.formGroup, { flex: 1, marginRight: spacing.sm }]}>
<Text style={styles.formLabel}>Precio de Venta *</Text>
<TextInput
style={styles.formInput}
value={formData.price}
onChangeText={(text) => setFormData({ ...formData, price: text })}
placeholder="0.00"
placeholderTextColor={colors.textMuted}
keyboardType="decimal-pad"
/>
</View>
<View style={[styles.formGroup, { flex: 1 }]}>
<Text style={styles.formLabel}>Costo</Text>
<TextInput
style={styles.formInput}
value={formData.cost}
onChangeText={(text) => setFormData({ ...formData, cost: text })}
placeholder="0.00"
placeholderTextColor={colors.textMuted}
keyboardType="decimal-pad"
/>
</View>
</View>
<View style={styles.formRow}>
<View style={[styles.formGroup, { flex: 1, marginRight: spacing.sm }]}>
<Text style={styles.formLabel}>Stock Actual</Text>
<TextInput
style={styles.formInput}
value={formData.currentStock}
onChangeText={(text) => setFormData({ ...formData, currentStock: text })}
placeholder="0"
placeholderTextColor={colors.textMuted}
keyboardType="number-pad"
/>
</View>
<View style={[styles.formGroup, { flex: 1 }]}>
<Text style={styles.formLabel}>Stock Minimo</Text>
<TextInput
style={styles.formInput}
value={formData.minStock}
onChangeText={(text) => setFormData({ ...formData, minStock: text })}
placeholder="5"
placeholderTextColor={colors.textMuted}
keyboardType="number-pad"
/>
</View>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Categoria</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.categorySelector}
>
<TouchableOpacity
style={[
styles.categorySelectorItem,
!formData.categoryId && styles.categorySelectorItemActive
]}
onPress={() => setFormData({ ...formData, categoryId: '' })}
>
<Text style={[
styles.categorySelectorText,
!formData.categoryId && styles.categorySelectorTextActive
]}>
Sin categoria
</Text>
</TouchableOpacity>
{categories.map((cat) => (
<TouchableOpacity
key={cat.id}
style={[
styles.categorySelectorItem,
formData.categoryId === cat.id && styles.categorySelectorItemActive
]}
onPress={() => setFormData({ ...formData, categoryId: cat.id })}
>
<Text style={[
styles.categorySelectorText,
formData.categoryId === cat.id && styles.categorySelectorTextActive
]}>
{cat.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</Modal>
);
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Productos</Text>
<TouchableOpacity style={styles.addButton} onPress={openCreateModal}>
<Text style={styles.addButtonText}>+ Nuevo</Text>
</TouchableOpacity>
</View>
{/* Search */}
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Buscar productos..."
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
{/* Category Filter */}
{renderCategoryFilter()}
{/* Products List */}
{loading ? (
<View style={styles.centerContainer}>
<Text style={styles.loadingText}>Cargando productos...</Text>
</View>
) : products.length === 0 ? (
<View style={styles.centerContainer}>
<Text style={styles.emptyIcon}>📦</Text>
<Text style={styles.emptyText}>No hay productos</Text>
<TouchableOpacity style={styles.emptyButton} onPress={openCreateModal}>
<Text style={styles.emptyButtonText}>Crear primer producto</Text>
</TouchableOpacity>
</View>
) : (
<FlatList
data={products}
keyExtractor={(item) => item.id}
renderItem={renderProduct}
contentContainerStyle={styles.listContent}
numColumns={2}
columnWrapperStyle={styles.columnWrapper}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
/>
)}
{renderFormModal()}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
title: {
fontSize: fontSize.xxl,
fontWeight: 'bold',
color: colors.text,
},
addButton: {
backgroundColor: colors.primary,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.md,
},
addButtonText: {
color: '#fff',
fontWeight: '600',
fontSize: fontSize.sm,
},
searchContainer: {
paddingHorizontal: spacing.lg,
marginBottom: spacing.sm,
},
searchInput: {
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: fontSize.md,
color: colors.text,
borderWidth: 1,
borderColor: colors.border,
},
categoryFilter: {
maxHeight: 50,
marginBottom: spacing.sm,
},
categoryFilterContent: {
paddingHorizontal: spacing.lg,
gap: spacing.sm,
},
categoryChip: {
backgroundColor: colors.surface,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: borderRadius.full,
borderWidth: 1,
borderColor: colors.border,
marginRight: spacing.sm,
},
categoryChipActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
categoryChipText: {
fontSize: fontSize.sm,
color: colors.textSecondary,
},
categoryChipTextActive: {
color: '#fff',
},
listContent: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.xl,
},
columnWrapper: {
justifyContent: 'space-between',
},
productCard: {
width: '48%',
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
marginBottom: spacing.md,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
},
productImageContainer: {
position: 'relative',
height: 120,
backgroundColor: colors.background,
},
productImage: {
width: '100%',
height: '100%',
resizeMode: 'cover',
},
productImagePlaceholder: {
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.primaryLight,
},
productImageText: {
fontSize: 40,
fontWeight: 'bold',
color: colors.primary,
},
favoriteIndicator: {
position: 'absolute',
top: spacing.xs,
left: spacing.xs,
backgroundColor: colors.warning,
borderRadius: borderRadius.full,
padding: 4,
},
productInfo: {
padding: spacing.sm,
},
productName: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.xs,
},
productSku: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginBottom: spacing.sm,
},
productFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
productPrice: {
fontSize: fontSize.lg,
fontWeight: 'bold',
color: colors.primary,
},
stockBadge: {
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: borderRadius.sm,
},
stockBadgeOk: {
backgroundColor: colors.successLight,
},
stockBadgeLow: {
backgroundColor: colors.errorLight,
},
stockText: {
fontSize: fontSize.xs,
fontWeight: '600',
},
stockTextOk: {
color: colors.success,
},
stockTextLow: {
color: colors.error,
},
favoriteButton: {
position: 'absolute',
top: spacing.xs,
right: spacing.xs,
backgroundColor: 'rgba(255,255,255,0.9)',
borderRadius: borderRadius.full,
padding: spacing.xs,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
loadingText: {
fontSize: fontSize.md,
color: colors.textMuted,
},
emptyIcon: {
fontSize: 60,
marginBottom: spacing.md,
},
emptyText: {
fontSize: fontSize.lg,
color: colors.textSecondary,
marginBottom: spacing.lg,
},
emptyButton: {
backgroundColor: colors.primary,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
},
emptyButtonText: {
color: '#fff',
fontWeight: '600',
},
// Modal styles
modalContainer: {
flex: 1,
backgroundColor: colors.background,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
modalCancel: {
fontSize: fontSize.md,
color: colors.textSecondary,
},
modalTitle: {
fontSize: fontSize.lg,
fontWeight: 'bold',
color: colors.text,
},
modalSave: {
fontSize: fontSize.md,
color: colors.primary,
fontWeight: '600',
},
modalSaveDisabled: {
opacity: 0.5,
},
formContainer: {
flex: 1,
padding: spacing.lg,
},
formGroup: {
marginBottom: spacing.md,
},
formRow: {
flexDirection: 'row',
},
formLabel: {
fontSize: fontSize.sm,
fontWeight: '600',
color: colors.textSecondary,
marginBottom: spacing.xs,
},
formInput: {
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: fontSize.md,
color: colors.text,
borderWidth: 1,
borderColor: colors.border,
},
formTextArea: {
minHeight: 80,
textAlignVertical: 'top',
},
categorySelector: {
flexDirection: 'row',
},
categorySelectorItem: {
backgroundColor: colors.surface,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
marginRight: spacing.sm,
},
categorySelectorItemActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
categorySelectorText: {
fontSize: fontSize.sm,
color: colors.textSecondary,
},
categorySelectorTextActive: {
color: '#fff',
},
});