Migración desde michangarrito/apps/mobile - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:26:57 -06:00
parent 25acb645bc
commit a71600df2f
30 changed files with 16399 additions and 3 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

30
App.tsx Normal file
View File

@ -0,0 +1,30 @@
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StyleSheet } from 'react-native';
import { AuthProvider } from './src/contexts/AuthContext';
import { OfflineSyncProvider } from './src/contexts/OfflineSyncContext';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return (
<GestureHandlerRootView style={styles.container}>
<SafeAreaProvider>
<AuthProvider>
<OfflineSyncProvider>
<AppNavigator />
<StatusBar style="auto" />
</OfflineSyncProvider>
</AuthProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

View File

@ -1,3 +0,0 @@
# michangarrito-mobile-v2
Mobile de michangarrito - Workspace V2

61
app.json Normal file
View File

@ -0,0 +1,61 @@
{
"expo": {
"name": "MiChangarrito",
"slug": "michangarrito",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"scheme": "michangarrito",
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#10B981"
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.michangarrito.app",
"associatedDomains": ["applinks:michangarrito.com"],
"infoPlist": {
"NSCameraUsageDescription": "Necesitamos acceso a la camara para escanear codigos de barras",
"UIBackgroundModes": ["fetch", "remote-notification"]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#10B981"
},
"package": "com.michangarrito.app",
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "michangarrito"
},
{
"scheme": "https",
"host": "michangarrito.com",
"pathPrefix": "/r"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
},
"web": {
"favicon": "./assets/favicon.png"
},
"extra": {
"apiUrl": "http://localhost:3141/api/v1"
},
"plugins": [
"expo-secure-store"
]
}
}

BIN
assets/adaptive-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
index.ts Normal file
View File

@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

9621
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "mobile",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-navigation/bottom-tabs": "^7.9.0",
"@react-navigation/native": "^7.1.26",
"@react-navigation/native-stack": "^7.9.0",
"axios": "^1.13.2",
"expo": "~54.0.31",
"expo-barcode-scanner": "^13.0.1",
"expo-camera": "^17.0.10",
"expo-constants": "^18.0.13",
"expo-secure-store": "^15.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "^2.30.0",
"react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.19.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

79
src/constants/theme.ts Normal file
View File

@ -0,0 +1,79 @@
export const colors = {
primary: '#10B981', // Emerald green
primaryDark: '#059669',
primaryLight: '#D1FAE5',
secondary: '#3B82F6', // Blue
secondaryDark: '#2563EB',
success: '#22C55E',
successLight: '#DCFCE7',
warning: '#F59E0B',
warningLight: '#FEF3C7',
error: '#EF4444',
errorLight: '#FEE2E2',
background: '#F9FAFB',
surface: '#FFFFFF',
text: '#111827',
textSecondary: '#6B7280',
textMuted: '#9CA3AF',
border: '#E5E7EB',
divider: '#F3F4F6',
white: '#FFFFFF',
black: '#000000',
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
};
export const fontSize = {
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 20,
xxl: 24,
xxxl: 32,
};
export const borderRadius = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
full: 9999,
};
export const shadows = {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
};

View File

@ -0,0 +1,103 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import * as SecureStore from 'expo-secure-store';
import { User, Tenant, AuthState } from '../types';
import apiService from '../services/api';
interface AuthContextType extends AuthState {
login: (phone: string, pin: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
user: null,
tenant: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: true,
});
useEffect(() => {
loadStoredAuth();
}, []);
const loadStoredAuth = async () => {
try {
const [accessToken, refreshToken, userJson, tenantJson] = await Promise.all([
SecureStore.getItemAsync('accessToken'),
SecureStore.getItemAsync('refreshToken'),
SecureStore.getItemAsync('user'),
SecureStore.getItemAsync('tenant'),
]);
if (accessToken && userJson && tenantJson) {
const user = JSON.parse(userJson) as User;
const tenant = JSON.parse(tenantJson) as Tenant;
setState({
user,
tenant,
accessToken,
refreshToken,
isAuthenticated: true,
isLoading: false,
});
} else {
setState((prev) => ({ ...prev, isLoading: false }));
}
} catch (error) {
console.error('Error loading auth:', error);
setState((prev) => ({ ...prev, isLoading: false }));
}
};
const login = async (phone: string, pin: string) => {
try {
const response = await apiService.login(phone, pin);
setState({
user: response.user,
tenant: response.tenant,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
throw error;
}
};
const logout = async () => {
try {
await apiService.logout();
setState({
user: null,
tenant: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
});
} catch (error) {
console.error('Error logging out:', error);
}
};
return (
<AuthContext.Provider value={{ ...state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@ -0,0 +1,178 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { offlineStorage } from '../services/offlineStorage';
import api from '../services/api';
interface OfflineSyncContextType {
isOnline: boolean;
isSyncing: boolean;
pendingSalesCount: number;
lastSyncAt: Date | null;
error: string | null;
syncNow: () => Promise<void>;
downloadDataForOffline: () => Promise<void>;
createOfflineSale: (saleData: {
items: Array<{ productId: string; quantity: number; unitPrice: number }>;
paymentMethod: string;
customerId?: string;
total: number;
}) => Promise<string>;
}
const OfflineSyncContext = createContext<OfflineSyncContextType | undefined>(undefined);
export function OfflineSyncProvider({ children }: { children: React.ReactNode }) {
const [isOnline, setIsOnline] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const [pendingSalesCount, setPendingSalesCount] = useState(0);
const [lastSyncAt, setLastSyncAt] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
// Load offline stats
const loadOfflineStats = useCallback(async () => {
try {
const stats = await offlineStorage.getOfflineStats();
setPendingSalesCount(stats.pendingSalesCount);
if (stats.lastSync) {
setLastSyncAt(new Date(stats.lastSync));
}
} catch (err) {
console.error('Error loading offline stats:', err);
}
}, []);
// Sync pending sales
const syncPendingSales = useCallback(async () => {
if (isSyncing) return;
setIsSyncing(true);
setError(null);
try {
const unsyncedSales = await offlineStorage.getUnsyncedSales();
if (unsyncedSales.length === 0) {
setIsSyncing(false);
return;
}
let syncedCount = 0;
const errors: string[] = [];
for (const sale of unsyncedSales) {
try {
await api.post('/sales', {
items: sale.items,
paymentMethod: sale.paymentMethod,
customerId: sale.customerId,
});
await offlineStorage.markSaleAsSynced(sale.id);
syncedCount++;
} catch (err: any) {
errors.push(`Venta ${sale.id}: ${err.message}`);
}
}
await offlineStorage.setLastSync(new Date().toISOString());
await loadOfflineStats();
if (errors.length > 0) {
setError(`${syncedCount} ventas sincronizadas, ${errors.length} errores`);
}
} catch (err: any) {
setError(err.message || 'Error al sincronizar');
} finally {
setIsSyncing(false);
}
}, [isSyncing, loadOfflineStats]);
// Download data for offline use
const downloadDataForOffline = useCallback(async () => {
setIsSyncing(true);
setError(null);
try {
const [productsRes, customersRes] = await Promise.all([
api.get('/products'),
api.get('/customers'),
]);
const products = productsRes.data.data || productsRes.data;
const customers = customersRes.data.data || customersRes.data;
await Promise.all([
offlineStorage.saveProducts(products),
offlineStorage.saveCustomers(customers),
offlineStorage.setLastSync(new Date().toISOString()),
]);
await loadOfflineStats();
} catch (err: any) {
setError(err.message || 'Error al descargar datos');
} finally {
setIsSyncing(false);
}
}, [loadOfflineStats]);
// Create offline sale
const createOfflineSale = useCallback(async (saleData: {
items: Array<{ productId: string; quantity: number; unitPrice: number }>;
paymentMethod: string;
customerId?: string;
total: number;
}) => {
const saleId = await offlineStorage.savePendingSale(saleData);
await loadOfflineStats();
return saleId;
}, [loadOfflineStats]);
// Monitor network status
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
const online = state.isConnected && state.isInternetReachable !== false;
setIsOnline(!!online);
// Auto-sync when coming back online
if (online && pendingSalesCount > 0) {
syncPendingSales();
}
});
// Initial load
loadOfflineStats();
return () => unsubscribe();
}, []);
// Trigger sync when coming online with pending sales
useEffect(() => {
if (isOnline && pendingSalesCount > 0 && !isSyncing) {
syncPendingSales();
}
}, [isOnline, pendingSalesCount]);
const value: OfflineSyncContextType = {
isOnline,
isSyncing,
pendingSalesCount,
lastSyncAt,
error,
syncNow: syncPendingSales,
downloadDataForOffline,
createOfflineSale,
};
return (
<OfflineSyncContext.Provider value={value}>
{children}
</OfflineSyncContext.Provider>
);
}
export function useOfflineSyncContext() {
const context = useContext(OfflineSyncContext);
if (context === undefined) {
throw new Error('useOfflineSyncContext must be used within an OfflineSyncProvider');
}
return context;
}

157
src/hooks/useOfflineSync.ts Normal file
View File

@ -0,0 +1,157 @@
import { useState, useEffect, useCallback } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { offlineStorage } from '../services/offlineStorage';
import apiService from '../services/api';
interface SyncStatus {
isOnline: boolean;
isSyncing: boolean;
pendingSalesCount: number;
lastSync: string | null;
error: string | null;
}
export function useOfflineSync() {
const [status, setStatus] = useState<SyncStatus>({
isOnline: true,
isSyncing: false,
pendingSalesCount: 0,
lastSync: null,
error: null,
});
// Monitor network status
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
const isOnline = state.isConnected && state.isInternetReachable !== false;
setStatus((prev) => ({ ...prev, isOnline: !!isOnline }));
// Auto-sync when coming back online
if (isOnline) {
syncPendingSales();
}
});
// Initial check
loadOfflineStats();
return () => unsubscribe();
}, []);
const loadOfflineStats = async () => {
try {
const stats = await offlineStorage.getOfflineStats();
setStatus((prev) => ({
...prev,
pendingSalesCount: stats.pendingSalesCount,
lastSync: stats.lastSync,
}));
} catch (error) {
console.error('Error loading offline stats:', error);
}
};
const syncPendingSales = useCallback(async () => {
if (status.isSyncing) return;
setStatus((prev) => ({ ...prev, isSyncing: true, error: null }));
try {
const unsyncedSales = await offlineStorage.getUnsyncedSales();
if (unsyncedSales.length === 0) {
setStatus((prev) => ({ ...prev, isSyncing: false }));
return;
}
let syncedCount = 0;
const errors: string[] = [];
for (const sale of unsyncedSales) {
try {
await apiService.createSale({
items: sale.items,
paymentMethod: sale.paymentMethod,
customerId: sale.customerId,
});
await offlineStorage.markSaleAsSynced(sale.id);
syncedCount++;
} catch (error: any) {
errors.push(`Venta ${sale.id}: ${error.message}`);
}
}
// Update last sync time
await offlineStorage.setLastSync(new Date().toISOString());
// Refresh stats
await loadOfflineStats();
if (errors.length > 0) {
setStatus((prev) => ({
...prev,
isSyncing: false,
error: `${syncedCount} ventas sincronizadas, ${errors.length} errores`,
}));
} else {
setStatus((prev) => ({ ...prev, isSyncing: false }));
}
} catch (error: any) {
setStatus((prev) => ({
...prev,
isSyncing: false,
error: error.message || 'Error al sincronizar',
}));
}
}, [status.isSyncing]);
const downloadDataForOffline = useCallback(async () => {
setStatus((prev) => ({ ...prev, isSyncing: true, error: null }));
try {
const [products, customers] = await Promise.all([
apiService.getProducts(),
apiService.getCustomers(),
]);
await Promise.all([
offlineStorage.saveProducts(products),
offlineStorage.saveCustomers(customers),
offlineStorage.setLastSync(new Date().toISOString()),
]);
await loadOfflineStats();
setStatus((prev) => ({ ...prev, isSyncing: false }));
} catch (error: any) {
setStatus((prev) => ({
...prev,
isSyncing: false,
error: error.message || 'Error al descargar datos',
}));
}
}, []);
const createOfflineSale = useCallback(
async (saleData: {
items: Array<{ productId: string; quantity: number; unitPrice: number }>;
paymentMethod: string;
customerId?: string;
total: number;
}) => {
const saleId = await offlineStorage.savePendingSale(saleData);
await loadOfflineStats();
return saleId;
},
[]
);
return {
...status,
syncPendingSales,
downloadDataForOffline,
createOfflineSale,
refreshStats: loadOfflineStats,
};
}
export default useOfflineSync;

View File

@ -0,0 +1,242 @@
import React from 'react';
import { ActivityIndicator, View, StyleSheet, Linking } from 'react-native';
import { NavigationContainer, LinkingOptions } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Text } from 'react-native';
import { useAuth } from '../contexts/AuthContext';
import { colors, fontSize } from '../constants/theme';
// Deep linking configuration
const linking: LinkingOptions<RootStackParamList> = {
prefixes: ['michangarrito://', 'https://michangarrito.com'],
config: {
screens: {
Main: {
screens: {
Dashboard: 'dashboard',
POS: 'pos',
Reports: 'reports',
More: 'more',
},
},
Products: 'products',
Inventory: 'inventory',
Customers: 'customers',
Settings: 'settings',
BarcodeScanner: 'scan',
},
},
async getInitialURL() {
// Handle app opened from deep link
const url = await Linking.getInitialURL();
if (url != null) {
return url;
}
return null;
},
subscribe(listener) {
// Listen to incoming links while app is open
const subscription = Linking.addEventListener('url', ({ url }) => {
listener(url);
});
return () => {
subscription.remove();
};
},
};
// Screens
import LoginScreen from '../screens/LoginScreen';
import DashboardScreen from '../screens/DashboardScreen';
import POSScreen from '../screens/POSScreen';
import MoreScreen from '../screens/MoreScreen';
import ProductsScreen from '../screens/ProductsScreen';
import InventoryScreen from '../screens/InventoryScreen';
import CustomersScreen from '../screens/CustomersScreen';
import ReportsScreen from '../screens/ReportsScreen';
import SettingsScreen from '../screens/SettingsScreen';
import BarcodeScannerScreen from '../screens/BarcodeScannerScreen';
// Type definitions
export type RootStackParamList = {
Auth: undefined;
Main: undefined;
Products: undefined;
Inventory: undefined;
Customers: undefined;
Reports: undefined;
Settings: undefined;
BarcodeScanner: { onScan?: (barcode: string) => void };
};
export type MainTabParamList = {
Dashboard: undefined;
POS: undefined;
Reports: undefined;
More: undefined;
};
const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabParamList>();
// Tab Icon Component
const TabIcon = ({ name, focused }: { name: string; focused: boolean }) => {
const icons: Record<string, string> = {
Dashboard: '🏠',
POS: '🛒',
Reports: '📊',
More: '☰',
};
return (
<Text style={{ fontSize: focused ? 24 : 20 }}>
{icons[name] || '•'}
</Text>
);
};
// Main Tab Navigator
function MainTabs() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused }) => (
<TabIcon name={route.name} focused={focused} />
),
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
paddingTop: 8,
height: 60,
},
tabBarLabelStyle: {
fontSize: fontSize.xs,
fontWeight: '500',
},
headerShown: false,
})}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{ tabBarLabel: 'Inicio' }}
/>
<Tab.Screen
name="POS"
component={POSScreen}
options={{ tabBarLabel: 'Vender' }}
/>
<Tab.Screen
name="Reports"
component={ReportsScreen}
options={{ tabBarLabel: 'Reportes' }}
/>
<Tab.Screen
name="More"
component={MoreScreen}
options={{ tabBarLabel: 'Mas' }}
/>
</Tab.Navigator>
);
}
// Loading Screen
function LoadingScreen() {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
// Main App Navigator
export default function AppNavigator() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <LoadingScreen />;
}
return (
<NavigationContainer linking={linking}>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{isAuthenticated ? (
<>
<Stack.Screen name="Main" component={MainTabs} />
<Stack.Screen
name="Products"
component={ProductsScreen}
options={{
headerShown: true,
headerTitle: 'Productos',
headerBackTitle: 'Atras',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
}}
/>
<Stack.Screen
name="Inventory"
component={InventoryScreen}
options={{
headerShown: true,
headerTitle: 'Inventario',
headerBackTitle: 'Atras',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
}}
/>
<Stack.Screen
name="Customers"
component={CustomersScreen}
options={{
headerShown: true,
headerTitle: 'Clientes',
headerBackTitle: 'Atras',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
}}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{
headerShown: true,
headerTitle: 'Ajustes',
headerBackTitle: 'Atras',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
}}
/>
<Stack.Screen
name="BarcodeScanner"
component={BarcodeScannerScreen}
options={{
headerShown: true,
headerTitle: 'Escanear Codigo',
headerBackTitle: 'Cancelar',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
presentation: 'modal',
}}
/>
</>
) : (
<Stack.Screen name="Auth" component={LoginScreen} />
)}
</Stack.Navigator>
</NavigationContainer>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background,
},
});

View File

@ -0,0 +1,314 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
Vibration,
} from 'react-native';
import { CameraView, Camera } from 'expo-camera';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { colors, spacing, fontSize, borderRadius } from '../constants/theme';
import apiService from '../services/api';
import { Product } from '../types';
interface BarcodeScannerScreenProps {
onProductScanned?: (product: Product) => void;
}
export default function BarcodeScannerScreen({ onProductScanned }: BarcodeScannerScreenProps) {
const navigation = useNavigation();
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [scanned, setScanned] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [flashOn, setFlashOn] = useState(false);
useEffect(() => {
requestCameraPermission();
}, []);
const requestCameraPermission = async () => {
const { status } = await Camera.requestCameraPermissionsAsync();
setHasPermission(status === 'granted');
};
const handleBarCodeScanned = async ({ type, data }: { type: string; data: string }) => {
if (scanned || isProcessing) return;
setScanned(true);
setIsProcessing(true);
Vibration.vibrate(100);
try {
const product = await apiService.getProductByBarcode(data);
if (product) {
if (onProductScanned) {
onProductScanned(product);
navigation.goBack();
} else {
Alert.alert(
'Producto Encontrado',
`${product.name}\nPrecio: $${product.price.toFixed(2)}\nStock: ${product.stock}`,
[
{ text: 'Escanear otro', onPress: () => setScanned(false) },
{ text: 'Cerrar', onPress: () => navigation.goBack() },
]
);
}
} else {
Alert.alert(
'Producto no encontrado',
`Codigo: ${data}\n\nEste producto no esta registrado.`,
[
{ text: 'Escanear otro', onPress: () => setScanned(false) },
{ text: 'Cerrar', onPress: () => navigation.goBack() },
]
);
}
} catch (error) {
Alert.alert(
'Error',
'No se pudo buscar el producto. Intenta de nuevo.',
[{ text: 'OK', onPress: () => setScanned(false) }]
);
} finally {
setIsProcessing(false);
}
};
if (hasPermission === null) {
return (
<View style={styles.container}>
<Text style={styles.messageText}>Solicitando permiso de camara...</Text>
</View>
);
}
if (hasPermission === false) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.permissionContainer}>
<Text style={styles.permissionTitle}>Permiso de Camara</Text>
<Text style={styles.permissionText}>
Necesitamos acceso a la camara para escanear codigos de barras.
</Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestCameraPermission}>
<Text style={styles.permissionButtonText}>Permitir Acceso</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.cancelButton} onPress={() => navigation.goBack()}>
<Text style={styles.cancelButtonText}>Cancelar</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<View style={styles.container}>
<CameraView
style={styles.camera}
facing="back"
enableTorch={flashOn}
barcodeScannerSettings={{
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e', 'code128', 'code39', 'qr'],
}}
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
>
{/* Overlay */}
<View style={styles.overlay}>
{/* Top */}
<View style={styles.overlayTop} />
{/* Middle row with scanner window */}
<View style={styles.overlayMiddle}>
<View style={styles.overlaySide} />
<View style={styles.scannerWindow}>
<View style={[styles.corner, styles.cornerTopLeft]} />
<View style={[styles.corner, styles.cornerTopRight]} />
<View style={[styles.corner, styles.cornerBottomLeft]} />
<View style={[styles.corner, styles.cornerBottomRight]} />
{isProcessing && (
<View style={styles.processingOverlay}>
<Text style={styles.processingText}>Buscando...</Text>
</View>
)}
</View>
<View style={styles.overlaySide} />
</View>
{/* Bottom */}
<View style={styles.overlayBottom}>
<Text style={styles.instructionText}>
Apunta al codigo de barras del producto
</Text>
</View>
</View>
{/* Controls */}
<SafeAreaView style={styles.controls}>
<TouchableOpacity style={styles.controlButton} onPress={() => navigation.goBack()}>
<Text style={styles.controlButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.controlButton, flashOn && styles.controlButtonActive]}
onPress={() => setFlashOn(!flashOn)}
>
<Text style={styles.controlButtonText}>{flashOn ? '⚡' : '🔦'}</Text>
</TouchableOpacity>
</SafeAreaView>
</CameraView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.black,
},
camera: {
flex: 1,
},
messageText: {
color: colors.white,
fontSize: fontSize.md,
textAlign: 'center',
marginTop: 100,
},
permissionContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
permissionTitle: {
fontSize: fontSize.xxl,
fontWeight: 'bold',
color: colors.text,
marginBottom: spacing.md,
},
permissionText: {
fontSize: fontSize.md,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
},
permissionButton: {
backgroundColor: colors.primary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
marginBottom: spacing.md,
},
permissionButtonText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: '600',
},
cancelButton: {
padding: spacing.md,
},
cancelButtonText: {
color: colors.textMuted,
fontSize: fontSize.md,
},
overlay: {
...StyleSheet.absoluteFillObject,
},
overlayTop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
},
overlayMiddle: {
flexDirection: 'row',
},
overlaySide: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
},
scannerWindow: {
width: 280,
height: 180,
position: 'relative',
},
corner: {
position: 'absolute',
width: 30,
height: 30,
borderColor: colors.primary,
},
cornerTopLeft: {
top: 0,
left: 0,
borderTopWidth: 4,
borderLeftWidth: 4,
},
cornerTopRight: {
top: 0,
right: 0,
borderTopWidth: 4,
borderRightWidth: 4,
},
cornerBottomLeft: {
bottom: 0,
left: 0,
borderBottomWidth: 4,
borderLeftWidth: 4,
},
cornerBottomRight: {
bottom: 0,
right: 0,
borderBottomWidth: 4,
borderRightWidth: 4,
},
processingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
processingText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: '600',
},
overlayBottom: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'flex-start',
alignItems: 'center',
paddingTop: spacing.xl,
},
instructionText: {
color: colors.white,
fontSize: fontSize.md,
textAlign: 'center',
},
controls: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-between',
padding: spacing.md,
},
controlButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
controlButtonActive: {
backgroundColor: colors.primary,
},
controlButtonText: {
fontSize: 20,
},
});

View File

@ -0,0 +1,847 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
RefreshControl,
Alert,
Modal,
ScrollView,
KeyboardAvoidingView,
Platform,
Linking,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, fontSize, borderRadius } from '../constants/theme';
import api from '../services/api';
interface Customer {
id: string;
name: string;
phone?: string;
email?: string;
address?: string;
notes?: string;
totalPurchases?: number;
totalSpent?: number;
lastPurchaseAt?: string;
createdAt: string;
}
interface CustomerFormData {
name: string;
phone: string;
email: string;
address: string;
notes: string;
}
const initialFormData: CustomerFormData = {
name: '',
phone: '',
email: '',
address: '',
notes: '',
};
export default function CustomersScreen() {
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [modalVisible, setModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
const [formData, setFormData] = useState<CustomerFormData>(initialFormData);
const [saving, setSaving] = useState(false);
const fetchCustomers = useCallback(async () => {
try {
const params: any = {};
if (searchQuery) params.search = searchQuery;
const response = await api.get('/customers', { params });
setCustomers(response.data.data || response.data);
} catch (error) {
console.error('Error fetching customers:', error);
}
}, [searchQuery]);
useEffect(() => {
const loadData = async () => {
setLoading(true);
await fetchCustomers();
setLoading(false);
};
loadData();
}, []);
useEffect(() => {
const delayedSearch = setTimeout(() => {
fetchCustomers();
}, 300);
return () => clearTimeout(delayedSearch);
}, [searchQuery]);
const onRefresh = async () => {
setRefreshing(true);
await fetchCustomers();
setRefreshing(false);
};
const openCreateModal = () => {
setEditingCustomer(null);
setFormData(initialFormData);
setModalVisible(true);
};
const openEditModal = (customer: Customer) => {
setEditingCustomer(customer);
setFormData({
name: customer.name,
phone: customer.phone || '',
email: customer.email || '',
address: customer.address || '',
notes: customer.notes || '',
});
setModalVisible(true);
};
const openDetailModal = (customer: Customer) => {
setSelectedCustomer(customer);
setDetailModalVisible(true);
};
const handleSave = async () => {
if (!formData.name.trim()) {
Alert.alert('Error', 'El nombre del cliente es requerido');
return;
}
setSaving(true);
try {
const payload = {
name: formData.name.trim(),
phone: formData.phone.trim() || null,
email: formData.email.trim() || null,
address: formData.address.trim() || null,
notes: formData.notes.trim() || null,
};
if (editingCustomer) {
await api.put(`/customers/${editingCustomer.id}`, payload);
Alert.alert('Exito', 'Cliente actualizado correctamente');
} else {
await api.post('/customers', payload);
Alert.alert('Exito', 'Cliente creado correctamente');
}
setModalVisible(false);
fetchCustomers();
} catch (error: any) {
Alert.alert('Error', error.response?.data?.message || 'No se pudo guardar el cliente');
} finally {
setSaving(false);
}
};
const handleDelete = (customer: Customer) => {
Alert.alert(
'Eliminar Cliente',
`¿Estas seguro de eliminar a "${customer.name}"?`,
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Eliminar',
style: 'destructive',
onPress: async () => {
try {
await api.delete(`/customers/${customer.id}`);
fetchCustomers();
setDetailModalVisible(false);
} catch (error: any) {
Alert.alert('Error', 'No se pudo eliminar el cliente');
}
},
},
]
);
};
const handleCall = (phone: string) => {
Linking.openURL(`tel:${phone}`);
};
const handleWhatsApp = (phone: string) => {
// Remove non-numeric characters and add country code if needed
const cleanPhone = phone.replace(/\D/g, '');
const whatsappUrl = `whatsapp://send?phone=52${cleanPhone}`;
Linking.openURL(whatsappUrl).catch(() => {
Alert.alert('Error', 'No se pudo abrir WhatsApp');
});
};
const handleEmail = (email: string) => {
Linking.openURL(`mailto:${email}`);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
const renderCustomer = ({ item }: { item: Customer }) => (
<TouchableOpacity
style={styles.customerCard}
onPress={() => openDetailModal(item)}
>
<View style={styles.customerAvatar}>
<Text style={styles.customerAvatarText}>
{item.name.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.customerInfo}>
<Text style={styles.customerName}>{item.name}</Text>
{item.phone && (
<Text style={styles.customerContact}>{item.phone}</Text>
)}
{item.totalPurchases !== undefined && (
<Text style={styles.customerStats}>
{item.totalPurchases} compras - ${item.totalSpent?.toFixed(2) || '0.00'}
</Text>
)}
</View>
{item.phone && (
<View style={styles.quickActions}>
<TouchableOpacity
style={styles.quickActionButton}
onPress={() => handleCall(item.phone!)}
>
<Text>📞</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.quickActionButton, styles.whatsappButton]}
onPress={() => handleWhatsApp(item.phone!)}
>
<Text>💬</Text>
</TouchableOpacity>
</View>
)}
</TouchableOpacity>
);
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}>
{editingCustomer ? 'Editar Cliente' : 'Nuevo Cliente'}
</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 cliente"
placeholderTextColor={colors.textMuted}
autoCapitalize="words"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Telefono</Text>
<TextInput
style={styles.formInput}
value={formData.phone}
onChangeText={(text) => setFormData({ ...formData, phone: text })}
placeholder="55 1234 5678"
placeholderTextColor={colors.textMuted}
keyboardType="phone-pad"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Email</Text>
<TextInput
style={styles.formInput}
value={formData.email}
onChangeText={(text) => setFormData({ ...formData, email: text })}
placeholder="correo@ejemplo.com"
placeholderTextColor={colors.textMuted}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Direccion</Text>
<TextInput
style={[styles.formInput, styles.formTextArea]}
value={formData.address}
onChangeText={(text) => setFormData({ ...formData, address: text })}
placeholder="Direccion de entrega"
placeholderTextColor={colors.textMuted}
multiline
numberOfLines={2}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Notas</Text>
<TextInput
style={[styles.formInput, styles.formTextArea]}
value={formData.notes}
onChangeText={(text) => setFormData({ ...formData, notes: text })}
placeholder="Notas adicionales sobre el cliente"
placeholderTextColor={colors.textMuted}
multiline
numberOfLines={3}
/>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</Modal>
);
const renderDetailModal = () => (
<Modal
visible={detailModalVisible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setDetailModalVisible(false)}
>
<SafeAreaView style={styles.modalContainer}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setDetailModalVisible(false)}>
<Text style={styles.modalCancel}>Cerrar</Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>Detalle de Cliente</Text>
<TouchableOpacity onPress={() => {
setDetailModalVisible(false);
if (selectedCustomer) openEditModal(selectedCustomer);
}}>
<Text style={styles.modalSave}>Editar</Text>
</TouchableOpacity>
</View>
{selectedCustomer && (
<ScrollView style={styles.detailContainer}>
{/* Customer Header */}
<View style={styles.detailHeader}>
<View style={styles.detailAvatar}>
<Text style={styles.detailAvatarText}>
{selectedCustomer.name.charAt(0).toUpperCase()}
</Text>
</View>
<Text style={styles.detailName}>{selectedCustomer.name}</Text>
<Text style={styles.detailSince}>
Cliente desde {formatDate(selectedCustomer.createdAt)}
</Text>
</View>
{/* Stats */}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>
{selectedCustomer.totalPurchases || 0}
</Text>
<Text style={styles.statLabel}>Compras</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>
${selectedCustomer.totalSpent?.toFixed(2) || '0.00'}
</Text>
<Text style={styles.statLabel}>Total Gastado</Text>
</View>
</View>
{/* Contact Info */}
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Contacto</Text>
{selectedCustomer.phone && (
<View style={styles.contactRow}>
<View style={styles.contactInfo}>
<Text style={styles.contactLabel}>Telefono</Text>
<Text style={styles.contactValue}>{selectedCustomer.phone}</Text>
</View>
<View style={styles.contactActions}>
<TouchableOpacity
style={styles.contactButton}
onPress={() => handleCall(selectedCustomer.phone!)}
>
<Text style={styles.contactButtonText}>📞 Llamar</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.contactButton, styles.whatsappContactButton]}
onPress={() => handleWhatsApp(selectedCustomer.phone!)}
>
<Text style={styles.contactButtonText}>💬 WhatsApp</Text>
</TouchableOpacity>
</View>
</View>
)}
{selectedCustomer.email && (
<View style={styles.contactRow}>
<View style={styles.contactInfo}>
<Text style={styles.contactLabel}>Email</Text>
<Text style={styles.contactValue}>{selectedCustomer.email}</Text>
</View>
<TouchableOpacity
style={styles.contactButton}
onPress={() => handleEmail(selectedCustomer.email!)}
>
<Text style={styles.contactButtonText}> Enviar</Text>
</TouchableOpacity>
</View>
)}
{selectedCustomer.address && (
<View style={styles.contactRow}>
<View style={styles.contactInfo}>
<Text style={styles.contactLabel}>Direccion</Text>
<Text style={styles.contactValue}>{selectedCustomer.address}</Text>
</View>
</View>
)}
</View>
{/* Notes */}
{selectedCustomer.notes && (
<View style={styles.detailSection}>
<Text style={styles.detailSectionTitle}>Notas</Text>
<Text style={styles.notesText}>{selectedCustomer.notes}</Text>
</View>
)}
{/* Delete Button */}
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleDelete(selectedCustomer)}
>
<Text style={styles.deleteButtonText}>Eliminar Cliente</Text>
</TouchableOpacity>
</ScrollView>
)}
</SafeAreaView>
</Modal>
);
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Clientes</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 clientes..."
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
{/* Stats Summary */}
<View style={styles.summaryContainer}>
<Text style={styles.summaryText}>
{customers.length} cliente{customers.length !== 1 ? 's' : ''} registrado{customers.length !== 1 ? 's' : ''}
</Text>
</View>
{/* Customers List */}
{loading ? (
<View style={styles.centerContainer}>
<Text style={styles.loadingText}>Cargando clientes...</Text>
</View>
) : customers.length === 0 ? (
<View style={styles.centerContainer}>
<Text style={styles.emptyIcon}>👥</Text>
<Text style={styles.emptyText}>
{searchQuery ? 'No se encontraron clientes' : 'No hay clientes registrados'}
</Text>
{!searchQuery && (
<TouchableOpacity style={styles.emptyButton} onPress={openCreateModal}>
<Text style={styles.emptyButtonText}>Agregar primer cliente</Text>
</TouchableOpacity>
)}
</View>
) : (
<FlatList
data={customers}
keyExtractor={(item) => item.id}
renderItem={renderCustomer}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
/>
)}
{renderFormModal()}
{renderDetailModal()}
</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,
},
summaryContainer: {
paddingHorizontal: spacing.lg,
marginBottom: spacing.md,
},
summaryText: {
fontSize: fontSize.sm,
color: colors.textMuted,
},
listContent: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl,
},
customerCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
padding: spacing.md,
marginBottom: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
customerAvatar: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
customerAvatarText: {
fontSize: fontSize.xl,
fontWeight: 'bold',
color: colors.primary,
},
customerInfo: {
flex: 1,
},
customerName: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.text,
},
customerContact: {
fontSize: fontSize.sm,
color: colors.textSecondary,
marginTop: 2,
},
customerStats: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: 4,
},
quickActions: {
flexDirection: 'row',
gap: spacing.xs,
},
quickActionButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
whatsappButton: {
backgroundColor: '#25D366',
borderColor: '#25D366',
},
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,
textAlign: 'center',
},
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,
},
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',
},
// Detail modal styles
detailContainer: {
flex: 1,
},
detailHeader: {
alignItems: 'center',
paddingVertical: spacing.xl,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
detailAvatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
detailAvatarText: {
fontSize: 36,
fontWeight: 'bold',
color: colors.primary,
},
detailName: {
fontSize: fontSize.xl,
fontWeight: 'bold',
color: colors.text,
marginBottom: spacing.xs,
},
detailSince: {
fontSize: fontSize.sm,
color: colors.textMuted,
},
statsRow: {
flexDirection: 'row',
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
statItem: {
flex: 1,
alignItems: 'center',
},
statValue: {
fontSize: fontSize.xl,
fontWeight: 'bold',
color: colors.primary,
},
statLabel: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: spacing.xs,
},
detailSection: {
padding: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
detailSectionTitle: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.md,
},
contactRow: {
marginBottom: spacing.md,
},
contactInfo: {
marginBottom: spacing.sm,
},
contactLabel: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginBottom: 2,
},
contactValue: {
fontSize: fontSize.md,
color: colors.text,
},
contactActions: {
flexDirection: 'row',
gap: spacing.sm,
},
contactButton: {
backgroundColor: colors.surface,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
},
whatsappContactButton: {
backgroundColor: '#25D366',
borderColor: '#25D366',
},
contactButtonText: {
fontSize: fontSize.sm,
fontWeight: '500',
color: colors.text,
},
notesText: {
fontSize: fontSize.md,
color: colors.textSecondary,
lineHeight: 22,
},
deleteButton: {
margin: spacing.lg,
paddingVertical: spacing.md,
alignItems: 'center',
borderRadius: borderRadius.md,
backgroundColor: colors.errorLight,
},
deleteButtonText: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.error,
},
});

View File

@ -0,0 +1,313 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, fontSize, borderRadius, shadows } from '../constants/theme';
import { useAuth } from '../contexts/AuthContext';
import apiService from '../services/api';
import { DashboardStats, Sale } from '../types';
export default function DashboardScreen() {
const { user, tenant } = useAuth();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [recentSales, setRecentSales] = useState<Sale[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
const [statsData, salesData] = await Promise.all([
apiService.getTodaySales(),
apiService.getRecentSales(),
]);
setStats(statsData);
setRecentSales(salesData);
} catch (error) {
console.error('Error loading dashboard:', error);
} finally {
setIsLoading(false);
}
};
const handleRefresh = async () => {
setIsRefreshing(true);
await loadDashboardData();
setIsRefreshing(false);
};
const formatCurrency = (amount: number) => {
return `$${amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}`;
};
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Buenos dias';
if (hour < 18) return 'Buenas tardes';
return 'Buenas noches';
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
colors={[colors.primary]}
tintColor={colors.primary}
/>
}
>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.greeting}>{getGreeting()}</Text>
<Text style={styles.userName}>{user?.name || 'Usuario'}</Text>
</View>
<View style={styles.tenantBadge}>
<Text style={styles.tenantName}>{tenant?.name || 'Mi Negocio'}</Text>
</View>
</View>
{/* Stats Cards */}
<View style={styles.statsGrid}>
<View style={[styles.statCard, styles.statCardPrimary]}>
<Text style={styles.statLabel}>Ventas Hoy</Text>
<Text style={styles.statValuePrimary}>
{formatCurrency(stats?.totalRevenue || 0)}
</Text>
<Text style={styles.statSubtext}>
{stats?.totalSales || 0} ventas
</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statLabel}>Ticket Promedio</Text>
<Text style={styles.statValue}>
{formatCurrency(stats?.avgTicket || 0)}
</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statLabel}>Impuestos</Text>
<Text style={styles.statValue}>
{formatCurrency(stats?.totalTax || 0)}
</Text>
</View>
</View>
{/* Quick Actions */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Acciones Rapidas</Text>
<View style={styles.actionsGrid}>
<TouchableOpacity style={styles.actionButton}>
<Text style={styles.actionIcon}>+</Text>
<Text style={styles.actionLabel}>Nueva Venta</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Text style={styles.actionIcon}>📦</Text>
<Text style={styles.actionLabel}>Inventario</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Text style={styles.actionIcon}>👥</Text>
<Text style={styles.actionLabel}>Clientes</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton}>
<Text style={styles.actionIcon}>📋</Text>
<Text style={styles.actionLabel}>Pedidos</Text>
</TouchableOpacity>
</View>
</View>
{/* Recent Sales */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Ventas Recientes</Text>
{recentSales.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
No hay ventas recientes
</Text>
<Text style={styles.emptyStateSubtext}>
Las ventas que realices apareceran aqui
</Text>
</View>
) : (
recentSales.slice(0, 5).map((sale) => (
<View key={sale.id} style={styles.saleItem}>
<View>
<Text style={styles.saleTicket}>#{sale.ticketNumber}</Text>
<Text style={styles.saleTime}>
{new Date(sale.createdAt).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</View>
<Text style={styles.saleAmount}>{formatCurrency(sale.total)}</Text>
</View>
))
)}
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: spacing.md,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.lg,
},
greeting: {
fontSize: fontSize.sm,
color: colors.textSecondary,
},
userName: {
fontSize: fontSize.xl,
fontWeight: 'bold',
color: colors.text,
},
tenantBadge: {
backgroundColor: colors.primaryLight + '20',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: borderRadius.full,
},
tenantName: {
fontSize: fontSize.sm,
color: colors.primary,
fontWeight: '600',
},
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
marginBottom: spacing.lg,
},
statCard: {
flex: 1,
minWidth: '45%',
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
padding: spacing.md,
...shadows.sm,
},
statCardPrimary: {
minWidth: '100%',
backgroundColor: colors.primary,
},
statLabel: {
fontSize: fontSize.sm,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
statValue: {
fontSize: fontSize.xl,
fontWeight: 'bold',
color: colors.text,
},
statValuePrimary: {
fontSize: fontSize.xxxl,
fontWeight: 'bold',
color: colors.white,
},
statSubtext: {
fontSize: fontSize.sm,
color: colors.white + 'CC',
marginTop: spacing.xs,
},
section: {
marginBottom: spacing.lg,
},
sectionTitle: {
fontSize: fontSize.lg,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.md,
},
actionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
},
actionButton: {
flex: 1,
minWidth: '45%',
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
padding: spacing.md,
alignItems: 'center',
...shadows.sm,
},
actionIcon: {
fontSize: 28,
marginBottom: spacing.xs,
},
actionLabel: {
fontSize: fontSize.sm,
color: colors.text,
fontWeight: '500',
},
emptyState: {
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: 'center',
},
emptyStateText: {
fontSize: fontSize.md,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
emptyStateSubtext: {
fontSize: fontSize.sm,
color: colors.textMuted,
},
saleItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
padding: spacing.md,
marginBottom: spacing.sm,
},
saleTicket: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.text,
},
saleTime: {
fontSize: fontSize.sm,
color: colors.textMuted,
},
saleAmount: {
fontSize: fontSize.lg,
fontWeight: 'bold',
color: colors.primary,
},
});

View File

@ -0,0 +1,712 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
RefreshControl,
Alert,
Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, fontSize, borderRadius } from '../constants/theme';
import api from '../services/api';
import { Product } from '../types';
type FilterType = 'all' | 'low_stock' | 'out_of_stock';
export default function InventoryScreen() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [filter, setFilter] = useState<FilterType>('all');
const [adjustModalVisible, setAdjustModalVisible] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [adjustQuantity, setAdjustQuantity] = useState('');
const [adjustReason, setAdjustReason] = useState('');
const fetchProducts = useCallback(async () => {
try {
let endpoint = '/products';
if (filter === 'low_stock') {
endpoint = '/products/low-stock';
}
const response = await api.get(endpoint);
let data = response.data.data || response.data;
// Apply filters
if (filter === 'out_of_stock') {
data = data.filter((p: Product) => p.currentStock === 0);
}
// Apply search
if (searchQuery) {
const query = searchQuery.toLowerCase();
data = data.filter((p: Product) =>
p.name.toLowerCase().includes(query) ||
p.sku?.toLowerCase().includes(query) ||
p.barcode?.includes(query)
);
}
setProducts(data);
} catch (error) {
console.error('Error fetching products:', error);
}
}, [filter, searchQuery]);
useEffect(() => {
const loadData = async () => {
setLoading(true);
await fetchProducts();
setLoading(false);
};
loadData();
}, []);
useEffect(() => {
const delayedSearch = setTimeout(() => {
fetchProducts();
}, 300);
return () => clearTimeout(delayedSearch);
}, [searchQuery, filter]);
const onRefresh = async () => {
setRefreshing(true);
await fetchProducts();
setRefreshing(false);
};
const openAdjustModal = (product: Product) => {
setSelectedProduct(product);
setAdjustQuantity('');
setAdjustReason('');
setAdjustModalVisible(true);
};
const handleAdjustStock = async () => {
if (!selectedProduct || !adjustQuantity) return;
const quantity = parseInt(adjustQuantity);
if (isNaN(quantity) || quantity === 0) {
Alert.alert('Error', 'Ingresa una cantidad valida');
return;
}
try {
await api.patch(`/products/${selectedProduct.id}/adjust-stock`, {
quantity,
reason: adjustReason || 'Ajuste manual',
});
Alert.alert('Exito', 'Stock ajustado correctamente');
setAdjustModalVisible(false);
fetchProducts();
} catch (error: any) {
Alert.alert('Error', error.response?.data?.message || 'No se pudo ajustar el stock');
}
};
const getStockStatus = (product: Product) => {
if (product.currentStock === 0) {
return { label: 'Agotado', color: colors.error, bgColor: colors.errorLight };
}
if (product.currentStock <= (product.minStock || 5)) {
return { label: 'Bajo', color: colors.warning, bgColor: colors.warningLight };
}
return { label: 'OK', color: colors.success, bgColor: colors.successLight };
};
const getTotalStats = () => {
const total = products.length;
const outOfStock = products.filter(p => p.currentStock === 0).length;
const lowStock = products.filter(p => p.currentStock > 0 && p.currentStock <= (p.minStock || 5)).length;
const totalValue = products.reduce((sum, p) => sum + (p.currentStock * p.price), 0);
return { total, outOfStock, lowStock, totalValue };
};
const stats = getTotalStats();
const renderProduct = ({ item }: { item: Product }) => {
const status = getStockStatus(item);
return (
<TouchableOpacity
style={styles.productCard}
onPress={() => openAdjustModal(item)}
>
<View style={styles.productHeader}>
<View style={styles.productInfo}>
<Text style={styles.productName} numberOfLines={1}>{item.name}</Text>
<Text style={styles.productSku}>{item.sku || 'Sin SKU'}</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: status.bgColor }]}>
<Text style={[styles.statusText, { color: status.color }]}>{status.label}</Text>
</View>
</View>
<View style={styles.productBody}>
<View style={styles.stockInfo}>
<Text style={styles.stockLabel}>Stock Actual</Text>
<Text style={[styles.stockValue, { color: status.color }]}>
{item.currentStock}
</Text>
</View>
<View style={styles.stockInfo}>
<Text style={styles.stockLabel}>Stock Min.</Text>
<Text style={styles.stockValueMuted}>{item.minStock || 5}</Text>
</View>
<View style={styles.stockInfo}>
<Text style={styles.stockLabel}>Valor</Text>
<Text style={styles.stockValueMuted}>
${(item.currentStock * item.price).toFixed(2)}
</Text>
</View>
</View>
<View style={styles.productFooter}>
<TouchableOpacity
style={styles.quickButton}
onPress={() => {
setSelectedProduct(item);
setAdjustQuantity('-1');
handleQuickAdjust(item, -1);
}}
>
<Text style={styles.quickButtonText}>- 1</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.quickButton, styles.quickButtonPrimary]}
onPress={() => openAdjustModal(item)}
>
<Text style={[styles.quickButtonText, styles.quickButtonTextPrimary]}>Ajustar</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.quickButton}
onPress={() => handleQuickAdjust(item, 1)}
>
<Text style={styles.quickButtonText}>+ 1</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
};
const handleQuickAdjust = async (product: Product, quantity: number) => {
try {
await api.patch(`/products/${product.id}/adjust-stock`, {
quantity,
reason: 'Ajuste rapido',
});
fetchProducts();
} catch (error) {
Alert.alert('Error', 'No se pudo ajustar el stock');
}
};
const renderFilterTabs = () => (
<View style={styles.filterTabs}>
<TouchableOpacity
style={[styles.filterTab, filter === 'all' && styles.filterTabActive]}
onPress={() => setFilter('all')}
>
<Text style={[styles.filterTabText, filter === 'all' && styles.filterTabTextActive]}>
Todos ({stats.total})
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterTab, filter === 'low_stock' && styles.filterTabActive]}
onPress={() => setFilter('low_stock')}
>
<Text style={[styles.filterTabText, filter === 'low_stock' && styles.filterTabTextActive]}>
Bajo Stock ({stats.lowStock})
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterTab, filter === 'out_of_stock' && styles.filterTabActive]}
onPress={() => setFilter('out_of_stock')}
>
<Text style={[styles.filterTabText, filter === 'out_of_stock' && styles.filterTabTextActive]}>
Agotados ({stats.outOfStock})
</Text>
</TouchableOpacity>
</View>
);
const renderAdjustModal = () => (
<Modal
visible={adjustModalVisible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setAdjustModalVisible(false)}
>
<SafeAreaView style={styles.modalContainer}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setAdjustModalVisible(false)}>
<Text style={styles.modalCancel}>Cancelar</Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>Ajustar Stock</Text>
<TouchableOpacity onPress={handleAdjustStock}>
<Text style={styles.modalSave}>Guardar</Text>
</TouchableOpacity>
</View>
{selectedProduct && (
<View style={styles.modalContent}>
<View style={styles.selectedProductInfo}>
<Text style={styles.selectedProductName}>{selectedProduct.name}</Text>
<Text style={styles.selectedProductStock}>
Stock actual: {selectedProduct.currentStock}
</Text>
</View>
<View style={styles.adjustForm}>
<Text style={styles.formLabel}>Cantidad a ajustar</Text>
<Text style={styles.formHint}>
Usa numeros negativos para restar (ej: -5)
</Text>
<TextInput
style={styles.adjustInput}
value={adjustQuantity}
onChangeText={setAdjustQuantity}
placeholder="Ej: 10 o -5"
placeholderTextColor={colors.textMuted}
keyboardType="number-pad"
autoFocus
/>
{adjustQuantity && !isNaN(parseInt(adjustQuantity)) && (
<View style={styles.previewContainer}>
<Text style={styles.previewText}>
Nuevo stock: {' '}
<Text style={styles.previewValue}>
{Math.max(0, selectedProduct.currentStock + parseInt(adjustQuantity))}
</Text>
</Text>
</View>
)}
<Text style={[styles.formLabel, { marginTop: spacing.lg }]}>
Razon del ajuste (opcional)
</Text>
<TextInput
style={styles.adjustInput}
value={adjustReason}
onChangeText={setAdjustReason}
placeholder="Ej: Recepcion de mercancia, Merma, etc."
placeholderTextColor={colors.textMuted}
/>
</View>
<View style={styles.quickAdjustButtons}>
<Text style={styles.quickAdjustLabel}>Ajustes rapidos:</Text>
<View style={styles.quickAdjustRow}>
{[-10, -5, -1, 1, 5, 10].map((qty) => (
<TouchableOpacity
key={qty}
style={[
styles.quickAdjustButton,
qty > 0 && styles.quickAdjustButtonPositive
]}
onPress={() => setAdjustQuantity(qty.toString())}
>
<Text style={[
styles.quickAdjustButtonText,
qty > 0 && styles.quickAdjustButtonTextPositive
]}>
{qty > 0 ? `+${qty}` : qty}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
)}
</SafeAreaView>
</Modal>
);
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Inventario</Text>
</View>
{/* Stats */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Text style={styles.statValue}>${stats.totalValue.toFixed(0)}</Text>
<Text style={styles.statLabel}>Valor Total</Text>
</View>
<View style={[styles.statCard, { backgroundColor: colors.warningLight }]}>
<Text style={[styles.statValue, { color: colors.warning }]}>{stats.lowStock}</Text>
<Text style={styles.statLabel}>Stock Bajo</Text>
</View>
<View style={[styles.statCard, { backgroundColor: colors.errorLight }]}>
<Text style={[styles.statValue, { color: colors.error }]}>{stats.outOfStock}</Text>
<Text style={styles.statLabel}>Agotados</Text>
</View>
</View>
{/* Search */}
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Buscar por nombre, SKU o codigo..."
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
{/* Filter Tabs */}
{renderFilterTabs()}
{/* Products List */}
{loading ? (
<View style={styles.centerContainer}>
<Text style={styles.loadingText}>Cargando inventario...</Text>
</View>
) : products.length === 0 ? (
<View style={styles.centerContainer}>
<Text style={styles.emptyIcon}>📦</Text>
<Text style={styles.emptyText}>
{filter === 'out_of_stock'
? 'No hay productos agotados'
: filter === 'low_stock'
? 'No hay productos con stock bajo'
: 'No hay productos'}
</Text>
</View>
) : (
<FlatList
data={products}
keyExtractor={(item) => item.id}
renderItem={renderProduct}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
/>
)}
{renderAdjustModal()}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
title: {
fontSize: fontSize.xxl,
fontWeight: 'bold',
color: colors.text,
},
statsContainer: {
flexDirection: 'row',
paddingHorizontal: spacing.lg,
marginBottom: spacing.md,
gap: spacing.sm,
},
statCard: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
padding: spacing.md,
alignItems: 'center',
},
statValue: {
fontSize: fontSize.xl,
fontWeight: 'bold',
color: colors.text,
},
statLabel: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: spacing.xs,
},
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,
},
filterTabs: {
flexDirection: 'row',
paddingHorizontal: spacing.lg,
marginBottom: spacing.md,
gap: spacing.sm,
},
filterTab: {
flex: 1,
paddingVertical: spacing.sm,
alignItems: 'center',
borderRadius: borderRadius.md,
backgroundColor: colors.surface,
},
filterTabActive: {
backgroundColor: colors.primary,
},
filterTabText: {
fontSize: fontSize.sm,
color: colors.textSecondary,
fontWeight: '500',
},
filterTabTextActive: {
color: '#fff',
},
listContent: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl,
},
productCard: {
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
padding: spacing.md,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
productHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: spacing.sm,
},
productInfo: {
flex: 1,
marginRight: spacing.sm,
},
productName: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.text,
},
productSku: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: 2,
},
statusBadge: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.sm,
},
statusText: {
fontSize: fontSize.xs,
fontWeight: '600',
},
productBody: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
borderBottomWidth: 1,
borderBottomColor: colors.border,
marginBottom: spacing.sm,
},
stockInfo: {
alignItems: 'center',
},
stockLabel: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginBottom: spacing.xs,
},
stockValue: {
fontSize: fontSize.xl,
fontWeight: 'bold',
},
stockValueMuted: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.textSecondary,
},
productFooter: {
flexDirection: 'row',
gap: spacing.sm,
},
quickButton: {
flex: 1,
paddingVertical: spacing.sm,
alignItems: 'center',
borderRadius: borderRadius.md,
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
},
quickButtonPrimary: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
quickButtonText: {
fontSize: fontSize.sm,
fontWeight: '600',
color: colors.textSecondary,
},
quickButtonTextPrimary: {
color: '#fff',
},
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,
textAlign: 'center',
},
// 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',
},
modalContent: {
flex: 1,
padding: spacing.lg,
},
selectedProductInfo: {
backgroundColor: colors.surface,
padding: spacing.md,
borderRadius: borderRadius.md,
marginBottom: spacing.lg,
},
selectedProductName: {
fontSize: fontSize.lg,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.xs,
},
selectedProductStock: {
fontSize: fontSize.md,
color: colors.textSecondary,
},
adjustForm: {},
formLabel: {
fontSize: fontSize.sm,
fontWeight: '600',
color: colors.textSecondary,
marginBottom: spacing.xs,
},
formHint: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginBottom: spacing.sm,
},
adjustInput: {
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
fontSize: fontSize.xl,
color: colors.text,
borderWidth: 1,
borderColor: colors.border,
textAlign: 'center',
},
previewContainer: {
backgroundColor: colors.primaryLight,
padding: spacing.md,
borderRadius: borderRadius.md,
marginTop: spacing.md,
alignItems: 'center',
},
previewText: {
fontSize: fontSize.md,
color: colors.textSecondary,
},
previewValue: {
fontSize: fontSize.xl,
fontWeight: 'bold',
color: colors.primary,
},
quickAdjustButtons: {
marginTop: spacing.xl,
},
quickAdjustLabel: {
fontSize: fontSize.sm,
color: colors.textMuted,
marginBottom: spacing.sm,
},
quickAdjustRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
},
quickAdjustButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.md,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
quickAdjustButtonPositive: {
backgroundColor: colors.successLight,
borderColor: colors.success,
},
quickAdjustButtonText: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.textSecondary,
},
quickAdjustButtonTextPositive: {
color: colors.success,
},
});

276
src/screens/LoginScreen.tsx Normal file
View File

@ -0,0 +1,276 @@
import React, { useState, useRef } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, fontSize, borderRadius } from '../constants/theme';
import { useAuth } from '../contexts/AuthContext';
export default function LoginScreen() {
const { login } = useAuth();
const [phone, setPhone] = useState('');
const [pin, setPin] = useState(['', '', '', '']);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const pinRefs = useRef<(TextInput | null)[]>([]);
const handlePinChange = (value: string, index: number) => {
if (value.length > 1) return;
const newPin = [...pin];
newPin[index] = value;
setPin(newPin);
setError('');
// Auto-focus next input
if (value && index < 3) {
pinRefs.current[index + 1]?.focus();
}
// Auto-submit when complete
if (index === 3 && value) {
const fullPin = newPin.join('');
if (fullPin.length === 4 && phone.length >= 10) {
handleLogin(phone, fullPin);
}
}
};
const handlePinKeyPress = (key: string, index: number) => {
if (key === 'Backspace' && !pin[index] && index > 0) {
pinRefs.current[index - 1]?.focus();
}
};
const handleLogin = async (phoneNumber: string, pinCode: string) => {
if (phoneNumber.length < 10) {
setError('Ingresa un numero de telefono valido');
return;
}
setIsLoading(true);
setError('');
try {
await login(phoneNumber, pinCode);
} catch (err: any) {
const message = err.response?.data?.message || 'Error al iniciar sesion';
setError(message);
setPin(['', '', '', '']);
pinRefs.current[0]?.focus();
} finally {
setIsLoading(false);
}
};
const formatPhone = (value: string) => {
const numbers = value.replace(/\D/g, '');
return numbers.slice(0, 10);
};
return (
<SafeAreaView style={styles.container}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.content}
>
{/* Logo and Title */}
<View style={styles.header}>
<View style={styles.logo}>
<Text style={styles.logoText}>MC</Text>
</View>
<Text style={styles.title}>MiChangarrito</Text>
<Text style={styles.subtitle}>Punto de Venta Inteligente</Text>
</View>
{/* Phone Input */}
<View style={styles.inputGroup}>
<Text style={styles.label}>Numero de telefono</Text>
<View style={styles.phoneContainer}>
<Text style={styles.countryCode}>+52</Text>
<TextInput
style={styles.phoneInput}
placeholder="10 digitos"
placeholderTextColor={colors.textMuted}
keyboardType="phone-pad"
maxLength={10}
value={phone}
onChangeText={(text) => setPhone(formatPhone(text))}
editable={!isLoading}
/>
</View>
</View>
{/* PIN Input */}
<View style={styles.inputGroup}>
<Text style={styles.label}>PIN de acceso</Text>
<View style={styles.pinContainer}>
{pin.map((digit, index) => (
<TextInput
key={index}
ref={(ref) => { pinRefs.current[index] = ref; }}
style={[styles.pinInput, error ? styles.pinInputError : null]}
keyboardType="number-pad"
maxLength={1}
secureTextEntry
value={digit}
onChangeText={(value) => handlePinChange(value, index)}
onKeyPress={({ nativeEvent }) => handlePinKeyPress(nativeEvent.key, index)}
editable={!isLoading}
/>
))}
</View>
</View>
{/* Error Message */}
{error ? <Text style={styles.errorText}>{error}</Text> : null}
{/* Login Button */}
<TouchableOpacity
style={[styles.loginButton, isLoading && styles.loginButtonDisabled]}
onPress={() => handleLogin(phone, pin.join(''))}
disabled={isLoading || phone.length < 10 || pin.join('').length < 4}
>
{isLoading ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.loginButtonText}>Entrar</Text>
)}
</TouchableOpacity>
{/* Help Text */}
<Text style={styles.helpText}>
Si es tu primera vez, tu negocio sera registrado automaticamente
</Text>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: spacing.xxl,
},
logo: {
width: 80,
height: 80,
borderRadius: borderRadius.xl,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
logoText: {
fontSize: 32,
fontWeight: 'bold',
color: colors.white,
},
title: {
fontSize: fontSize.xxxl,
fontWeight: 'bold',
color: colors.text,
marginBottom: spacing.xs,
},
subtitle: {
fontSize: fontSize.md,
color: colors.textSecondary,
},
inputGroup: {
marginBottom: spacing.lg,
},
label: {
fontSize: fontSize.sm,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.sm,
},
phoneContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
},
countryCode: {
paddingHorizontal: spacing.md,
fontSize: fontSize.lg,
color: colors.textSecondary,
borderRightWidth: 1,
borderRightColor: colors.border,
},
phoneInput: {
flex: 1,
padding: spacing.md,
fontSize: fontSize.lg,
color: colors.text,
},
pinContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: spacing.md,
},
pinInput: {
flex: 1,
height: 56,
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.border,
textAlign: 'center',
fontSize: fontSize.xxl,
fontWeight: 'bold',
color: colors.text,
},
pinInputError: {
borderColor: colors.error,
},
errorText: {
color: colors.error,
fontSize: fontSize.sm,
textAlign: 'center',
marginBottom: spacing.md,
},
loginButton: {
backgroundColor: colors.primary,
borderRadius: borderRadius.lg,
padding: spacing.md,
alignItems: 'center',
marginTop: spacing.md,
},
loginButtonDisabled: {
opacity: 0.6,
},
loginButtonText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: '600',
},
helpText: {
fontSize: fontSize.sm,
color: colors.textMuted,
textAlign: 'center',
marginTop: spacing.lg,
paddingHorizontal: spacing.lg,
},
});

403
src/screens/MoreScreen.tsx Normal file
View File

@ -0,0 +1,403 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Alert,
Linking,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { colors, spacing, fontSize, borderRadius } from '../constants/theme';
import { useAuth } from '../contexts/AuthContext';
import { RootStackParamList } from '../navigation/AppNavigator';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export default function MoreScreen() {
const navigation = useNavigation<NavigationProp>();
const { user, tenant, logout } = useAuth();
const handleLogout = () => {
Alert.alert(
'Cerrar Sesion',
'Estas seguro que deseas salir?',
[
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Salir', style: 'destructive', onPress: logout },
]
);
};
const handleWhatsAppSupport = () => {
Linking.openURL('https://wa.me/525512345678?text=Hola,%20necesito%20ayuda%20con%20MiChangarrito');
};
const handleHelp = () => {
Linking.openURL('https://michangarrito.mx/ayuda');
};
const menuItems = [
{
title: 'Productos',
icon: '📦',
description: 'Gestionar catalogo',
onPress: () => navigation.navigate('Products'),
},
{
title: 'Inventario',
icon: '📊',
description: 'Control de stock',
onPress: () => navigation.navigate('Inventory'),
},
{
title: 'Clientes',
icon: '👥',
description: 'Directorio de clientes',
onPress: () => navigation.navigate('Customers'),
},
{
title: 'Escanear Codigo',
icon: '📷',
description: 'Buscar por codigo de barras',
onPress: () => navigation.navigate('BarcodeScanner', {}),
},
];
const settingsItems = [
{
title: 'Configuracion',
icon: '⚙️',
onPress: () => navigation.navigate('Settings'),
},
];
return (
<SafeAreaView style={styles.container} edges={['top']}>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Menu</Text>
</View>
{/* Profile Card */}
<TouchableOpacity
style={styles.profileCard}
onPress={() => navigation.navigate('Settings')}
>
<View style={styles.avatar}>
<Text style={styles.avatarText}>
{user?.name?.charAt(0).toUpperCase() || 'U'}
</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.userName}>{user?.name || 'Usuario'}</Text>
<Text style={styles.userPhone}>{user?.phone || ''}</Text>
<Text style={styles.tenantName}>{tenant?.name || 'Mi Negocio'}</Text>
</View>
<Text style={styles.profileArrow}></Text>
</TouchableOpacity>
{/* Subscription Badge */}
<View style={styles.subscriptionBadge}>
<View style={styles.subscriptionInfo}>
<Text style={styles.subscriptionLabel}>Plan Actual</Text>
<Text style={styles.subscriptionPlan}>
{tenant?.subscriptionStatus === 'trial' ? 'Prueba Gratuita' : 'Plan Activo'}
</Text>
</View>
<TouchableOpacity style={styles.upgradeBadge}>
<Text style={styles.upgradeText}>Mejorar</Text>
</TouchableOpacity>
</View>
{/* Main Menu Items */}
<View style={styles.menuSection}>
<Text style={styles.sectionTitle}>Gestion</Text>
<View style={styles.menuCard}>
{menuItems.map((item, index) => (
<TouchableOpacity
key={index}
style={[
styles.menuItem,
index === menuItems.length - 1 && styles.menuItemLast,
]}
onPress={item.onPress}
>
<View style={styles.menuIconContainer}>
<Text style={styles.menuIcon}>{item.icon}</Text>
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>{item.title}</Text>
{item.description && (
<Text style={styles.menuDescription}>{item.description}</Text>
)}
</View>
<Text style={styles.menuArrow}></Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Settings Items */}
<View style={styles.menuSection}>
<Text style={styles.sectionTitle}>Preferencias</Text>
<View style={styles.menuCard}>
{settingsItems.map((item, index) => (
<TouchableOpacity
key={index}
style={[
styles.menuItem,
index === settingsItems.length - 1 && styles.menuItemLast,
]}
onPress={item.onPress}
>
<View style={styles.menuIconContainer}>
<Text style={styles.menuIcon}>{item.icon}</Text>
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>{item.title}</Text>
</View>
<Text style={styles.menuArrow}></Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Support Section */}
<View style={styles.menuSection}>
<Text style={styles.sectionTitle}>Soporte</Text>
<View style={styles.menuCard}>
<TouchableOpacity
style={styles.menuItem}
onPress={handleHelp}
>
<View style={styles.menuIconContainer}>
<Text style={styles.menuIcon}></Text>
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>Centro de Ayuda</Text>
<Text style={styles.menuDescription}>Guias y tutoriales</Text>
</View>
<Text style={styles.menuArrow}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.menuItem, styles.menuItemLast]}
onPress={handleWhatsAppSupport}
>
<View style={[styles.menuIconContainer, styles.whatsappIcon]}>
<Text style={styles.menuIcon}>💬</Text>
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>Contactar Soporte</Text>
<Text style={styles.menuDescription}>WhatsApp disponible 24/7</Text>
</View>
<Text style={styles.menuArrow}></Text>
</TouchableOpacity>
</View>
</View>
{/* Logout Button */}
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutText}>Cerrar Sesion</Text>
</TouchableOpacity>
{/* Version */}
<View style={styles.footer}>
<Text style={styles.version}>MiChangarrito v1.0.0</Text>
<Text style={styles.copyright}>Hecho con en Mexico</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
scrollView: {
flex: 1,
},
header: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
headerTitle: {
fontSize: fontSize.xxl,
fontWeight: 'bold',
color: colors.text,
},
profileCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
padding: spacing.lg,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.border,
},
avatar: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
avatarText: {
fontSize: fontSize.xl,
fontWeight: 'bold',
color: '#fff',
},
profileInfo: {
flex: 1,
},
userName: {
fontSize: fontSize.lg,
fontWeight: '600',
color: colors.text,
},
userPhone: {
fontSize: fontSize.sm,
color: colors.textSecondary,
marginTop: 2,
},
tenantName: {
fontSize: fontSize.sm,
color: colors.primary,
marginTop: 4,
fontWeight: '500',
},
profileArrow: {
fontSize: fontSize.xxl,
color: colors.textMuted,
},
subscriptionBadge: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.primaryLight,
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
padding: spacing.md,
borderRadius: borderRadius.lg,
},
subscriptionInfo: {},
subscriptionLabel: {
fontSize: fontSize.xs,
color: colors.textSecondary,
},
subscriptionPlan: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.primary,
marginTop: 2,
},
upgradeBadge: {
backgroundColor: colors.primary,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.md,
},
upgradeText: {
fontSize: fontSize.sm,
fontWeight: '600',
color: '#fff',
},
menuSection: {
marginBottom: spacing.lg,
paddingHorizontal: spacing.lg,
},
sectionTitle: {
fontSize: fontSize.sm,
fontWeight: '600',
color: colors.textMuted,
marginBottom: spacing.sm,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
menuCard: {
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
menuItemLast: {
borderBottomWidth: 0,
},
menuIconContainer: {
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
whatsappIcon: {
backgroundColor: '#25D366' + '20',
},
menuIcon: {
fontSize: 20,
},
menuContent: {
flex: 1,
},
menuTitle: {
fontSize: fontSize.md,
fontWeight: '500',
color: colors.text,
},
menuDescription: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: 2,
},
menuArrow: {
fontSize: fontSize.xl,
color: colors.textMuted,
},
logoutButton: {
backgroundColor: colors.errorLight,
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
padding: spacing.md,
borderRadius: borderRadius.lg,
alignItems: 'center',
},
logoutText: {
color: colors.error,
fontSize: fontSize.md,
fontWeight: '600',
},
footer: {
alignItems: 'center',
paddingVertical: spacing.lg,
marginBottom: spacing.lg,
},
version: {
color: colors.textMuted,
fontSize: fontSize.sm,
},
copyright: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.xs,
},
});

600
src/screens/POSScreen.tsx Normal file
View File

@ -0,0 +1,600 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, fontSize, borderRadius, shadows } from '../constants/theme';
import apiService from '../services/api';
import { Product, Category, CartItem } from '../types';
export default function POSScreen() {
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [cart, setCart] = useState<CartItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [showCart, setShowCart] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [productsData, categoriesData] = await Promise.all([
apiService.getProducts(),
apiService.getCategories(),
]);
setProducts(productsData);
setCategories(categoriesData);
} catch (error) {
console.error('Error loading data:', error);
} finally {
setIsLoading(false);
}
};
const filteredProducts = products.filter((product) => {
const matchesCategory = !selectedCategory || product.categoryId === selectedCategory;
const matchesSearch =
!searchQuery ||
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
product.barcode?.includes(searchQuery);
return matchesCategory && matchesSearch && product.isActive;
});
const addToCart = (product: Product) => {
if (product.stock <= 0) {
Alert.alert('Sin stock', 'Este producto no tiene stock disponible');
return;
}
setCart((prevCart) => {
const existingItem = prevCart.find((item) => item.product.id === product.id);
if (existingItem) {
if (existingItem.quantity >= product.stock) {
Alert.alert('Stock limitado', `Solo hay ${product.stock} unidades disponibles`);
return prevCart;
}
return prevCart.map((item) =>
item.product.id === product.id
? {
...item,
quantity: item.quantity + 1,
subtotal: (item.quantity + 1) * item.product.price,
}
: item
);
}
return [
...prevCart,
{
product,
quantity: 1,
subtotal: product.price,
},
];
});
};
const removeFromCart = (productId: string) => {
setCart((prevCart) => {
const existingItem = prevCart.find((item) => item.product.id === productId);
if (existingItem && existingItem.quantity > 1) {
return prevCart.map((item) =>
item.product.id === productId
? {
...item,
quantity: item.quantity - 1,
subtotal: (item.quantity - 1) * item.product.price,
}
: item
);
}
return prevCart.filter((item) => item.product.id !== productId);
});
};
const clearCart = () => {
setCart([]);
setShowCart(false);
};
const cartTotal = cart.reduce((sum, item) => sum + item.subtotal, 0);
const cartItemsCount = cart.reduce((sum, item) => sum + item.quantity, 0);
const handleCheckout = async (paymentMethod: string) => {
if (cart.length === 0) return;
setIsProcessing(true);
try {
const saleData = {
items: cart.map((item) => ({
productId: item.product.id,
quantity: item.quantity,
unitPrice: item.product.price,
})),
paymentMethod,
};
await apiService.createSale(saleData);
Alert.alert('Venta completada', `Total: $${cartTotal.toFixed(2)}`, [
{ text: 'OK', onPress: clearCart },
]);
} catch (error: any) {
Alert.alert('Error', error.response?.data?.message || 'No se pudo procesar la venta');
} finally {
setIsProcessing(false);
}
};
const formatCurrency = (amount: number) => {
return `$${amount.toFixed(2)}`;
};
const renderProduct = ({ item }: { item: Product }) => (
<TouchableOpacity
style={[styles.productCard, item.stock <= 0 && styles.productCardDisabled]}
onPress={() => addToCart(item)}
disabled={item.stock <= 0}
>
<View style={styles.productInfo}>
<Text style={styles.productName} numberOfLines={2}>
{item.name}
</Text>
<Text style={styles.productPrice}>{formatCurrency(item.price)}</Text>
<Text style={[styles.productStock, item.stock <= item.minStock && styles.lowStock]}>
Stock: {item.stock}
</Text>
</View>
{item.isFavorite && <View style={styles.favoriteBadge}><Text></Text></View>}
</TouchableOpacity>
);
const renderCartItem = ({ item }: { item: CartItem }) => (
<View style={styles.cartItem}>
<View style={styles.cartItemInfo}>
<Text style={styles.cartItemName} numberOfLines={1}>
{item.product.name}
</Text>
<Text style={styles.cartItemPrice}>
{formatCurrency(item.product.price)} x {item.quantity}
</Text>
</View>
<View style={styles.cartItemActions}>
<TouchableOpacity
style={styles.quantityButton}
onPress={() => removeFromCart(item.product.id)}
>
<Text style={styles.quantityButtonText}>-</Text>
</TouchableOpacity>
<Text style={styles.quantityText}>{item.quantity}</Text>
<TouchableOpacity
style={styles.quantityButton}
onPress={() => addToCart(item.product)}
>
<Text style={styles.quantityButtonText}>+</Text>
</TouchableOpacity>
</View>
<Text style={styles.cartItemSubtotal}>{formatCurrency(item.subtotal)}</Text>
</View>
);
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Search Bar */}
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="Buscar producto o escanear codigo..."
placeholderTextColor={colors.textMuted}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
{/* Categories */}
<View style={styles.categoriesContainer}>
<ScrollViewHorizontal>
<TouchableOpacity
style={[styles.categoryChip, !selectedCategory && styles.categoryChipActive]}
onPress={() => setSelectedCategory(null)}
>
<Text style={[styles.categoryText, !selectedCategory && styles.categoryTextActive]}>
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.categoryText,
selectedCategory === cat.id && styles.categoryTextActive,
]}
>
{cat.name}
</Text>
</TouchableOpacity>
))}
</ScrollViewHorizontal>
</View>
{/* Products Grid */}
<FlatList
data={filteredProducts}
renderItem={renderProduct}
keyExtractor={(item) => item.id}
numColumns={2}
contentContainerStyle={styles.productsGrid}
columnWrapperStyle={styles.productRow}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No se encontraron productos</Text>
</View>
}
/>
{/* Cart Button */}
{cart.length > 0 && (
<TouchableOpacity style={styles.cartButton} onPress={() => setShowCart(true)}>
<View style={styles.cartBadge}>
<Text style={styles.cartBadgeText}>{cartItemsCount}</Text>
</View>
<Text style={styles.cartButtonText}>Ver Carrito</Text>
<Text style={styles.cartTotal}>{formatCurrency(cartTotal)}</Text>
</TouchableOpacity>
)}
{/* Cart Modal */}
<Modal visible={showCart} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<View style={styles.cartModal}>
<View style={styles.cartHeader}>
<Text style={styles.cartTitle}>Carrito ({cartItemsCount})</Text>
<TouchableOpacity onPress={() => setShowCart(false)}>
<Text style={styles.closeButton}></Text>
</TouchableOpacity>
</View>
<FlatList
data={cart}
renderItem={renderCartItem}
keyExtractor={(item) => item.product.id}
style={styles.cartList}
/>
<View style={styles.cartFooter}>
<View style={styles.cartTotalRow}>
<Text style={styles.cartTotalLabel}>Total:</Text>
<Text style={styles.cartTotalAmount}>{formatCurrency(cartTotal)}</Text>
</View>
<View style={styles.paymentButtons}>
<TouchableOpacity
style={[styles.paymentButton, styles.paymentButtonCash]}
onPress={() => handleCheckout('cash')}
disabled={isProcessing}
>
<Text style={styles.paymentButtonText}>Efectivo</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.paymentButton, styles.paymentButtonCard]}
onPress={() => handleCheckout('card')}
disabled={isProcessing}
>
<Text style={styles.paymentButtonText}>Tarjeta</Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.clearCartButton} onPress={clearCart}>
<Text style={styles.clearCartText}>Vaciar Carrito</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
// Simple horizontal scroll component
const ScrollViewHorizontal = ({ children }: { children: React.ReactNode }) => (
<View style={{ flexDirection: 'row', gap: spacing.sm }}>{children}</View>
);
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
searchContainer: {
padding: spacing.md,
paddingBottom: spacing.sm,
},
searchInput: {
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
padding: spacing.md,
fontSize: fontSize.md,
color: colors.text,
borderWidth: 1,
borderColor: colors.border,
},
categoriesContainer: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
},
categoryChip: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
categoryChipActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
categoryText: {
fontSize: fontSize.sm,
color: colors.text,
},
categoryTextActive: {
color: colors.white,
fontWeight: '600',
},
productsGrid: {
padding: spacing.md,
},
productRow: {
justifyContent: 'space-between',
marginBottom: spacing.md,
},
productCard: {
width: '48%',
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
padding: spacing.md,
...shadows.sm,
},
productCardDisabled: {
opacity: 0.5,
},
productInfo: {
flex: 1,
},
productName: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.xs,
},
productPrice: {
fontSize: fontSize.lg,
fontWeight: 'bold',
color: colors.primary,
marginBottom: spacing.xs,
},
productStock: {
fontSize: fontSize.xs,
color: colors.textMuted,
},
lowStock: {
color: colors.warning,
},
favoriteBadge: {
position: 'absolute',
top: spacing.sm,
right: spacing.sm,
},
emptyState: {
padding: spacing.xl,
alignItems: 'center',
},
emptyStateText: {
color: colors.textMuted,
fontSize: fontSize.md,
},
cartButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.primary,
margin: spacing.md,
padding: spacing.md,
borderRadius: borderRadius.lg,
},
cartBadge: {
backgroundColor: colors.white,
borderRadius: borderRadius.full,
width: 24,
height: 24,
justifyContent: 'center',
alignItems: 'center',
},
cartBadgeText: {
color: colors.primary,
fontWeight: 'bold',
fontSize: fontSize.sm,
},
cartButtonText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: '600',
flex: 1,
marginLeft: spacing.md,
},
cartTotal: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: 'bold',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
cartModal: {
backgroundColor: colors.surface,
borderTopLeftRadius: borderRadius.xl,
borderTopRightRadius: borderRadius.xl,
maxHeight: '80%',
},
cartHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
cartTitle: {
fontSize: fontSize.xl,
fontWeight: 'bold',
color: colors.text,
},
closeButton: {
fontSize: fontSize.xl,
color: colors.textMuted,
padding: spacing.sm,
},
cartList: {
maxHeight: 300,
},
cartItem: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
cartItemInfo: {
flex: 1,
},
cartItemName: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.text,
},
cartItemPrice: {
fontSize: fontSize.sm,
color: colors.textMuted,
},
cartItemActions: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: spacing.md,
},
quantityButton: {
width: 32,
height: 32,
borderRadius: borderRadius.md,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
quantityButtonText: {
fontSize: fontSize.lg,
fontWeight: 'bold',
color: colors.primary,
},
quantityText: {
fontSize: fontSize.md,
fontWeight: '600',
marginHorizontal: spacing.md,
color: colors.text,
},
cartItemSubtotal: {
fontSize: fontSize.md,
fontWeight: 'bold',
color: colors.text,
width: 70,
textAlign: 'right',
},
cartFooter: {
padding: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.border,
},
cartTotalRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
cartTotalLabel: {
fontSize: fontSize.lg,
color: colors.text,
},
cartTotalAmount: {
fontSize: fontSize.xxl,
fontWeight: 'bold',
color: colors.primary,
},
paymentButtons: {
flexDirection: 'row',
gap: spacing.md,
marginBottom: spacing.md,
},
paymentButton: {
flex: 1,
padding: spacing.md,
borderRadius: borderRadius.lg,
alignItems: 'center',
},
paymentButtonCash: {
backgroundColor: colors.primary,
},
paymentButtonCard: {
backgroundColor: colors.secondary,
},
paymentButtonText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: '600',
},
clearCartButton: {
alignItems: 'center',
padding: spacing.sm,
},
clearCartText: {
color: colors.error,
fontSize: fontSize.sm,
},
});

View File

@ -0,0 +1,804 @@
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',
},
});

View File

@ -0,0 +1,592 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
RefreshControl,
Dimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, fontSize, borderRadius } from '../constants/theme';
import api from '../services/api';
type PeriodType = 'today' | 'week' | 'month' | 'year';
interface SalesSummary {
totalSales: number;
totalRevenue: number;
averageTicket: number;
topProducts: Array<{
id: string;
name: string;
quantity: number;
revenue: number;
}>;
paymentMethods: Array<{
method: string;
count: number;
total: number;
}>;
hourlyDistribution?: Array<{
hour: number;
count: number;
revenue: number;
}>;
}
interface DailySales {
date: string;
sales: number;
revenue: number;
}
const screenWidth = Dimensions.get('window').width;
export default function ReportsScreen() {
const [period, setPeriod] = useState<PeriodType>('today');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [summary, setSummary] = useState<SalesSummary | null>(null);
const [dailySales, setDailySales] = useState<DailySales[]>([]);
const fetchData = async () => {
try {
// Fetch sales summary
const summaryResponse = await api.get('/sales/today');
const todayData = summaryResponse.data;
// Calculate summary from today's sales
const sales = todayData.data || todayData;
const totalRevenue = sales.reduce((sum: number, sale: any) => sum + parseFloat(sale.total), 0);
const averageTicket = sales.length > 0 ? totalRevenue / sales.length : 0;
// Group by payment method
const paymentMethodsMap: Record<string, { count: number; total: number }> = {};
sales.forEach((sale: any) => {
const method = sale.paymentMethod || 'efectivo';
if (!paymentMethodsMap[method]) {
paymentMethodsMap[method] = { count: 0, total: 0 };
}
paymentMethodsMap[method].count++;
paymentMethodsMap[method].total += parseFloat(sale.total);
});
const paymentMethods = Object.entries(paymentMethodsMap).map(([method, data]) => ({
method,
count: data.count,
total: data.total,
}));
// Calculate top products from items
const productsMap: Record<string, { name: string; quantity: number; revenue: number }> = {};
sales.forEach((sale: any) => {
if (sale.items) {
sale.items.forEach((item: any) => {
const productId = item.productId || item.id;
if (!productsMap[productId]) {
productsMap[productId] = {
name: item.productName || item.name || 'Producto',
quantity: 0,
revenue: 0,
};
}
productsMap[productId].quantity += item.quantity;
productsMap[productId].revenue += item.quantity * item.price;
});
}
});
const topProducts = Object.entries(productsMap)
.map(([id, data]) => ({ id, ...data }))
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 5);
setSummary({
totalSales: sales.length,
totalRevenue,
averageTicket,
topProducts,
paymentMethods,
});
} catch (error) {
console.error('Error fetching reports:', error);
// Set default empty data
setSummary({
totalSales: 0,
totalRevenue: 0,
averageTicket: 0,
topProducts: [],
paymentMethods: [],
});
}
};
useEffect(() => {
const loadData = async () => {
setLoading(true);
await fetchData();
setLoading(false);
};
loadData();
}, [period]);
const onRefresh = async () => {
setRefreshing(true);
await fetchData();
setRefreshing(false);
};
const formatCurrency = (value: number) => {
return `$${value.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,')}`;
};
const getPaymentMethodLabel = (method: string) => {
const labels: Record<string, string> = {
efectivo: 'Efectivo',
tarjeta: 'Tarjeta',
transferencia: 'Transferencia',
credito: 'Credito',
};
return labels[method] || method;
};
const getPaymentMethodIcon = (method: string) => {
const icons: Record<string, string> = {
efectivo: '💵',
tarjeta: '💳',
transferencia: '🏦',
credito: '📝',
};
return icons[method] || '💰';
};
const renderPeriodSelector = () => (
<View style={styles.periodSelector}>
{(['today', 'week', 'month', 'year'] as PeriodType[]).map((p) => (
<TouchableOpacity
key={p}
style={[styles.periodButton, period === p && styles.periodButtonActive]}
onPress={() => setPeriod(p)}
>
<Text style={[styles.periodButtonText, period === p && styles.periodButtonTextActive]}>
{p === 'today' ? 'Hoy' : p === 'week' ? 'Semana' : p === 'month' ? 'Mes' : 'Año'}
</Text>
</TouchableOpacity>
))}
</View>
);
const renderSummaryCards = () => (
<View style={styles.summaryCards}>
<View style={[styles.summaryCard, styles.revenueCard]}>
<Text style={styles.summaryCardValue}>
{formatCurrency(summary?.totalRevenue || 0)}
</Text>
<Text style={styles.summaryCardLabel}>Ventas Totales</Text>
</View>
<View style={styles.summaryRow}>
<View style={[styles.summaryCard, styles.smallCard]}>
<Text style={styles.summaryCardValue}>{summary?.totalSales || 0}</Text>
<Text style={styles.summaryCardLabel}>Transacciones</Text>
</View>
<View style={[styles.summaryCard, styles.smallCard]}>
<Text style={styles.summaryCardValue}>
{formatCurrency(summary?.averageTicket || 0)}
</Text>
<Text style={styles.summaryCardLabel}>Ticket Promedio</Text>
</View>
</View>
</View>
);
const renderPaymentMethods = () => {
if (!summary?.paymentMethods || summary.paymentMethods.length === 0) {
return null;
}
const total = summary.paymentMethods.reduce((sum, pm) => sum + pm.total, 0);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Metodos de Pago</Text>
<View style={styles.paymentMethodsContainer}>
{summary.paymentMethods.map((pm, index) => {
const percentage = total > 0 ? (pm.total / total) * 100 : 0;
return (
<View key={index} style={styles.paymentMethodRow}>
<View style={styles.paymentMethodInfo}>
<Text style={styles.paymentMethodIcon}>
{getPaymentMethodIcon(pm.method)}
</Text>
<View>
<Text style={styles.paymentMethodName}>
{getPaymentMethodLabel(pm.method)}
</Text>
<Text style={styles.paymentMethodCount}>
{pm.count} transaccion{pm.count !== 1 ? 'es' : ''}
</Text>
</View>
</View>
<View style={styles.paymentMethodValue}>
<Text style={styles.paymentMethodAmount}>
{formatCurrency(pm.total)}
</Text>
<Text style={styles.paymentMethodPercentage}>
{percentage.toFixed(1)}%
</Text>
</View>
</View>
);
})}
</View>
</View>
);
};
const renderTopProducts = () => {
if (!summary?.topProducts || summary.topProducts.length === 0) {
return null;
}
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Productos Mas Vendidos</Text>
<View style={styles.topProductsContainer}>
{summary.topProducts.map((product, index) => (
<View key={product.id || index} style={styles.topProductRow}>
<View style={styles.topProductRank}>
<Text style={styles.topProductRankText}>#{index + 1}</Text>
</View>
<View style={styles.topProductInfo}>
<Text style={styles.topProductName} numberOfLines={1}>
{product.name}
</Text>
<Text style={styles.topProductQuantity}>
{product.quantity} vendido{product.quantity !== 1 ? 's' : ''}
</Text>
</View>
<Text style={styles.topProductRevenue}>
{formatCurrency(product.revenue)}
</Text>
</View>
))}
</View>
</View>
);
};
const renderEmptyState = () => (
<View style={styles.emptyState}>
<Text style={styles.emptyIcon}>📊</Text>
<Text style={styles.emptyTitle}>Sin datos para mostrar</Text>
<Text style={styles.emptyText}>
No hay ventas registradas en este periodo.
Realiza tu primera venta para ver los reportes.
</Text>
</View>
);
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>Reportes</Text>
</View>
{/* Period Selector */}
{renderPeriodSelector()}
{/* Content */}
{loading ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Cargando reportes...</Text>
</View>
) : !summary || (summary.totalSales === 0 && summary.totalRevenue === 0) ? (
renderEmptyState()
) : (
<ScrollView
style={styles.content}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
showsVerticalScrollIndicator={false}
>
{renderSummaryCards()}
{renderPaymentMethods()}
{renderTopProducts()}
{/* Quick insights */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Insights</Text>
<View style={styles.insightsContainer}>
{summary.averageTicket > 0 && (
<View style={styles.insightCard}>
<Text style={styles.insightIcon}>💡</Text>
<Text style={styles.insightText}>
Tu ticket promedio es de {formatCurrency(summary.averageTicket)}.
{summary.averageTicket < 100
? ' Considera ofrecer productos complementarios.'
: ' Buen promedio de venta!'}
</Text>
</View>
)}
{summary.paymentMethods.length > 0 && (
<View style={styles.insightCard}>
<Text style={styles.insightIcon}>📈</Text>
<Text style={styles.insightText}>
El metodo de pago mas usado es {' '}
{getPaymentMethodLabel(
summary.paymentMethods.sort((a, b) => b.count - a.count)[0].method
).toLowerCase()}.
</Text>
</View>
)}
</View>
</View>
<View style={{ height: spacing.xl }} />
</ScrollView>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
title: {
fontSize: fontSize.xxl,
fontWeight: 'bold',
color: colors.text,
},
periodSelector: {
flexDirection: 'row',
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg,
gap: spacing.sm,
},
periodButton: {
flex: 1,
paddingVertical: spacing.sm,
alignItems: 'center',
borderRadius: borderRadius.md,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
periodButtonActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
periodButtonText: {
fontSize: fontSize.sm,
fontWeight: '500',
color: colors.textSecondary,
},
periodButtonTextActive: {
color: '#fff',
},
content: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: fontSize.md,
color: colors.textMuted,
},
summaryCards: {
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg,
},
summaryCard: {
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
padding: spacing.lg,
borderWidth: 1,
borderColor: colors.border,
},
revenueCard: {
backgroundColor: colors.primary,
borderColor: colors.primary,
marginBottom: spacing.md,
alignItems: 'center',
},
summaryRow: {
flexDirection: 'row',
gap: spacing.md,
},
smallCard: {
flex: 1,
alignItems: 'center',
},
summaryCardValue: {
fontSize: fontSize.xxl,
fontWeight: 'bold',
color: colors.text,
},
summaryCardLabel: {
fontSize: fontSize.sm,
color: colors.textMuted,
marginTop: spacing.xs,
},
section: {
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg,
},
sectionTitle: {
fontSize: fontSize.lg,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.md,
},
paymentMethodsContainer: {
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
},
paymentMethodRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
paymentMethodInfo: {
flexDirection: 'row',
alignItems: 'center',
},
paymentMethodIcon: {
fontSize: 24,
marginRight: spacing.md,
},
paymentMethodName: {
fontSize: fontSize.md,
fontWeight: '500',
color: colors.text,
},
paymentMethodCount: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: 2,
},
paymentMethodValue: {
alignItems: 'flex-end',
},
paymentMethodAmount: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.text,
},
paymentMethodPercentage: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: 2,
},
topProductsContainer: {
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
},
topProductRow: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
topProductRank: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
topProductRankText: {
fontSize: fontSize.sm,
fontWeight: 'bold',
color: colors.primary,
},
topProductInfo: {
flex: 1,
marginRight: spacing.md,
},
topProductName: {
fontSize: fontSize.md,
fontWeight: '500',
color: colors.text,
},
topProductQuantity: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: 2,
},
topProductRevenue: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.primary,
},
insightsContainer: {
gap: spacing.md,
},
insightCard: {
flexDirection: 'row',
backgroundColor: colors.primaryLight,
borderRadius: borderRadius.lg,
padding: spacing.md,
alignItems: 'flex-start',
},
insightIcon: {
fontSize: 20,
marginRight: spacing.sm,
},
insightText: {
flex: 1,
fontSize: fontSize.sm,
color: colors.textSecondary,
lineHeight: 20,
},
emptyState: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
emptyIcon: {
fontSize: 60,
marginBottom: spacing.md,
},
emptyTitle: {
fontSize: fontSize.lg,
fontWeight: '600',
color: colors.text,
marginBottom: spacing.sm,
},
emptyText: {
fontSize: fontSize.md,
color: colors.textMuted,
textAlign: 'center',
lineHeight: 22,
},
});

View File

@ -0,0 +1,506 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Switch,
Alert,
Linking,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors, spacing, fontSize, borderRadius } from '../constants/theme';
import { useAuth } from '../contexts/AuthContext';
interface AppSettings {
soundEnabled: boolean;
vibrationEnabled: boolean;
autoSync: boolean;
printAfterSale: boolean;
quickSaleMode: boolean;
showPricesWithTax: boolean;
defaultPaymentMethod: 'efectivo' | 'tarjeta' | 'transferencia';
}
const defaultSettings: AppSettings = {
soundEnabled: true,
vibrationEnabled: true,
autoSync: true,
printAfterSale: false,
quickSaleMode: false,
showPricesWithTax: true,
defaultPaymentMethod: 'efectivo',
};
const SETTINGS_KEY = '@michangarrito_settings';
export default function SettingsScreen({ navigation }: any) {
const { user, logout } = useAuth();
const [settings, setSettings] = useState<AppSettings>(defaultSettings);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const stored = await AsyncStorage.getItem(SETTINGS_KEY);
if (stored) {
setSettings({ ...defaultSettings, ...JSON.parse(stored) });
}
} catch (error) {
console.error('Error loading settings:', error);
} finally {
setLoading(false);
}
};
const saveSettings = async (newSettings: AppSettings) => {
try {
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(newSettings));
setSettings(newSettings);
} catch (error) {
console.error('Error saving settings:', error);
Alert.alert('Error', 'No se pudieron guardar los ajustes');
}
};
const updateSetting = <K extends keyof AppSettings>(
key: K,
value: AppSettings[K]
) => {
const newSettings = { ...settings, [key]: value };
saveSettings(newSettings);
};
const handleLogout = () => {
Alert.alert(
'Cerrar Sesion',
'¿Estas seguro de que deseas cerrar sesion?',
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Cerrar Sesion',
style: 'destructive',
onPress: () => logout(),
},
]
);
};
const handleClearCache = () => {
Alert.alert(
'Limpiar Cache',
'Esto eliminara los datos almacenados localmente. ¿Continuar?',
[
{ text: 'Cancelar', style: 'cancel' },
{
text: 'Limpiar',
style: 'destructive',
onPress: async () => {
try {
await AsyncStorage.multiRemove([
'@michangarrito_offline_products',
'@michangarrito_pending_sales',
]);
Alert.alert('Exito', 'Cache limpiado correctamente');
} catch (error) {
Alert.alert('Error', 'No se pudo limpiar el cache');
}
},
},
]
);
};
const handleSupport = () => {
Linking.openURL('mailto:soporte@michangarrito.mx?subject=Soporte App');
};
const handlePrivacyPolicy = () => {
Linking.openURL('https://michangarrito.mx/privacidad');
};
const handleTerms = () => {
Linking.openURL('https://michangarrito.mx/terminos');
};
const renderSettingSwitch = (
title: string,
description: string,
key: keyof AppSettings,
value: boolean
) => (
<View style={styles.settingRow}>
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>{title}</Text>
<Text style={styles.settingDescription}>{description}</Text>
</View>
<Switch
value={value}
onValueChange={(newValue) => updateSetting(key, newValue)}
trackColor={{ false: colors.border, true: colors.primaryLight }}
thumbColor={value ? colors.primary : colors.textMuted}
/>
</View>
);
const renderPaymentMethodSelector = () => (
<View style={styles.settingSection}>
<Text style={styles.sectionTitle}>Metodo de Pago Predeterminado</Text>
<View style={styles.paymentMethodOptions}>
{[
{ value: 'efectivo', label: 'Efectivo', icon: '💵' },
{ value: 'tarjeta', label: 'Tarjeta', icon: '💳' },
{ value: 'transferencia', label: 'Transferencia', icon: '🏦' },
].map((option) => (
<TouchableOpacity
key={option.value}
style={[
styles.paymentMethodOption,
settings.defaultPaymentMethod === option.value && styles.paymentMethodOptionActive,
]}
onPress={() => updateSetting('defaultPaymentMethod', option.value as any)}
>
<Text style={styles.paymentMethodIcon}>{option.icon}</Text>
<Text
style={[
styles.paymentMethodLabel,
settings.defaultPaymentMethod === option.value && styles.paymentMethodLabelActive,
]}
>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
);
if (loading) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Cargando ajustes...</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Ajustes</Text>
</View>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* User Profile Section */}
<View style={styles.profileSection}>
<View style={styles.profileAvatar}>
<Text style={styles.profileAvatarText}>
{user?.name?.charAt(0).toUpperCase() || 'U'}
</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>{user?.name || 'Usuario'}</Text>
<Text style={styles.profileRole}>{user?.role || 'owner'}</Text>
</View>
</View>
{/* App Settings */}
<View style={styles.settingSection}>
<Text style={styles.sectionTitle}>Aplicacion</Text>
<View style={styles.settingsCard}>
{renderSettingSwitch(
'Sonidos',
'Reproducir sonidos al completar acciones',
'soundEnabled',
settings.soundEnabled
)}
{renderSettingSwitch(
'Vibracion',
'Activar vibracion en eventos importantes',
'vibrationEnabled',
settings.vibrationEnabled
)}
{renderSettingSwitch(
'Sincronizacion automatica',
'Sincronizar datos cuando hay conexion',
'autoSync',
settings.autoSync
)}
</View>
</View>
{/* Sales Settings */}
<View style={styles.settingSection}>
<Text style={styles.sectionTitle}>Ventas</Text>
<View style={styles.settingsCard}>
{renderSettingSwitch(
'Modo venta rapida',
'Simplificar el proceso de venta',
'quickSaleMode',
settings.quickSaleMode
)}
{renderSettingSwitch(
'Mostrar precios con IVA',
'Incluir impuestos en los precios',
'showPricesWithTax',
settings.showPricesWithTax
)}
{renderSettingSwitch(
'Imprimir despues de venta',
'Enviar ticket a impresora automaticamente',
'printAfterSale',
settings.printAfterSale
)}
</View>
</View>
{/* Payment Method */}
{renderPaymentMethodSelector()}
{/* Data Management */}
<View style={styles.settingSection}>
<Text style={styles.sectionTitle}>Datos</Text>
<View style={styles.settingsCard}>
<TouchableOpacity style={styles.actionRow} onPress={handleClearCache}>
<View style={styles.actionInfo}>
<Text style={styles.actionTitle}>Limpiar cache</Text>
<Text style={styles.actionDescription}>
Eliminar datos temporales almacenados
</Text>
</View>
<Text style={styles.actionIcon}>🗑</Text>
</TouchableOpacity>
</View>
</View>
{/* Support & Legal */}
<View style={styles.settingSection}>
<Text style={styles.sectionTitle}>Soporte y Legal</Text>
<View style={styles.settingsCard}>
<TouchableOpacity style={styles.actionRow} onPress={handleSupport}>
<View style={styles.actionInfo}>
<Text style={styles.actionTitle}>Contactar soporte</Text>
<Text style={styles.actionDescription}>
Enviar un correo al equipo de soporte
</Text>
</View>
<Text style={styles.actionIcon}>📧</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionRow} onPress={handlePrivacyPolicy}>
<View style={styles.actionInfo}>
<Text style={styles.actionTitle}>Politica de privacidad</Text>
</View>
<Text style={styles.actionIcon}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionRow} onPress={handleTerms}>
<View style={styles.actionInfo}>
<Text style={styles.actionTitle}>Terminos y condiciones</Text>
</View>
<Text style={styles.actionIcon}></Text>
</TouchableOpacity>
</View>
</View>
{/* App Version */}
<View style={styles.versionSection}>
<Text style={styles.versionText}>MiChangarrito v1.0.0</Text>
<Text style={styles.versionSubtext}>Hecho con en Mexico</Text>
</View>
{/* Logout Button */}
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutButtonText}>Cerrar Sesion</Text>
</TouchableOpacity>
<View style={{ height: spacing.xl }} />
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
header: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
title: {
fontSize: fontSize.xxl,
fontWeight: 'bold',
color: colors.text,
},
content: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: fontSize.md,
color: colors.textMuted,
},
profileSection: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
marginBottom: spacing.md,
},
profileAvatar: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
profileAvatarText: {
fontSize: 24,
fontWeight: 'bold',
color: '#fff',
},
profileInfo: {
flex: 1,
},
profileName: {
fontSize: fontSize.lg,
fontWeight: '600',
color: colors.text,
},
profileRole: {
fontSize: fontSize.sm,
color: colors.textMuted,
textTransform: 'capitalize',
marginTop: 2,
},
settingSection: {
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg,
},
sectionTitle: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.textSecondary,
marginBottom: spacing.sm,
},
settingsCard: {
backgroundColor: colors.surface,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
},
settingRow: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
settingInfo: {
flex: 1,
marginRight: spacing.md,
},
settingTitle: {
fontSize: fontSize.md,
fontWeight: '500',
color: colors.text,
},
settingDescription: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: 2,
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
actionInfo: {
flex: 1,
},
actionTitle: {
fontSize: fontSize.md,
fontWeight: '500',
color: colors.text,
},
actionDescription: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: 2,
},
actionIcon: {
fontSize: 20,
marginLeft: spacing.sm,
},
paymentMethodOptions: {
flexDirection: 'row',
gap: spacing.sm,
},
paymentMethodOption: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: borderRadius.md,
padding: spacing.md,
alignItems: 'center',
borderWidth: 2,
borderColor: colors.border,
},
paymentMethodOptionActive: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
paymentMethodIcon: {
fontSize: 24,
marginBottom: spacing.xs,
},
paymentMethodLabel: {
fontSize: fontSize.sm,
fontWeight: '500',
color: colors.textSecondary,
},
paymentMethodLabelActive: {
color: colors.primary,
},
versionSection: {
alignItems: 'center',
paddingVertical: spacing.lg,
},
versionText: {
fontSize: fontSize.sm,
color: colors.textMuted,
},
versionSubtext: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginTop: spacing.xs,
},
logoutButton: {
marginHorizontal: spacing.lg,
backgroundColor: colors.errorLight,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
alignItems: 'center',
},
logoutButtonText: {
fontSize: fontSize.md,
fontWeight: '600',
color: colors.error,
},
});

209
src/services/api.ts Normal file
View File

@ -0,0 +1,209 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
const API_URL = Constants.expoConfig?.extra?.apiUrl || 'http://localhost:3141/api/v1';
class ApiService {
private api: AxiosInstance;
private refreshPromise: Promise<string> | null = null;
constructor() {
this.api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 15000,
});
this.setupInterceptors();
}
private setupInterceptors() {
// Request interceptor - add auth token
this.api.interceptors.request.use(
async (config) => {
const token = await SecureStore.getItemAsync('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - handle 401 and refresh token
this.api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config;
if (error.response?.status === 401 && originalRequest) {
try {
const newToken = await this.refreshToken();
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.api(originalRequest);
} catch {
// Refresh failed - clear tokens
await this.clearTokens();
throw error;
}
}
return Promise.reject(error);
}
);
}
private async refreshToken(): Promise<string> {
// Prevent multiple refresh calls
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = (async () => {
const refreshToken = await SecureStore.getItemAsync('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token');
}
const response = await axios.post(`${API_URL}/auth/refresh`, { refreshToken });
const { accessToken, refreshToken: newRefresh } = response.data;
await SecureStore.setItemAsync('accessToken', accessToken);
await SecureStore.setItemAsync('refreshToken', newRefresh);
return accessToken;
})();
try {
return await this.refreshPromise;
} finally {
this.refreshPromise = null;
}
}
private async clearTokens() {
await SecureStore.deleteItemAsync('accessToken');
await SecureStore.deleteItemAsync('refreshToken');
await SecureStore.deleteItemAsync('user');
await SecureStore.deleteItemAsync('tenant');
}
// Generic HTTP methods for direct access
async get<T = any>(url: string, config?: any) {
return this.api.get<T>(url, config);
}
async post<T = any>(url: string, data?: any, config?: any) {
return this.api.post<T>(url, data, config);
}
async put<T = any>(url: string, data?: any, config?: any) {
return this.api.put<T>(url, data, config);
}
async patch<T = any>(url: string, data?: any, config?: any) {
return this.api.patch<T>(url, data, config);
}
async delete<T = any>(url: string, config?: any) {
return this.api.delete<T>(url, config);
}
// Auth endpoints
async login(phone: string, pin: string) {
const response = await this.api.post('/auth/login', { phone, pin });
const { accessToken, refreshToken, user, tenant } = response.data;
await SecureStore.setItemAsync('accessToken', accessToken);
await SecureStore.setItemAsync('refreshToken', refreshToken);
await SecureStore.setItemAsync('user', JSON.stringify(user));
await SecureStore.setItemAsync('tenant', JSON.stringify(tenant));
return response.data;
}
async logout() {
await this.clearTokens();
}
// Products endpoints
async getProducts(params?: { category?: string; search?: string }) {
const response = await this.api.get('/products', { params });
return response.data;
}
async getProductByBarcode(barcode: string) {
const response = await this.api.get(`/products/barcode/${barcode}`);
return response.data;
}
async getFavoriteProducts() {
const response = await this.api.get('/products/favorites');
return response.data;
}
// Categories endpoints
async getCategories() {
const response = await this.api.get('/categories');
return response.data;
}
// Sales endpoints
async createSale(data: {
items: Array<{ productId: string; quantity: number; unitPrice: number }>;
paymentMethod: string;
customerId?: string;
}) {
const response = await this.api.post('/sales', data);
return response.data;
}
async getTodaySales() {
const response = await this.api.get('/sales/today');
return response.data;
}
async getRecentSales() {
const response = await this.api.get('/sales/recent');
return response.data;
}
// Customers endpoints
async getCustomers(params?: { search?: string }) {
const response = await this.api.get('/customers', { params });
return response.data;
}
async getCustomersWithFiados() {
const response = await this.api.get('/customers/with-fiados');
return response.data;
}
// Inventory endpoints
async getLowStockProducts() {
const response = await this.api.get('/inventory/low-stock');
return response.data;
}
async getInventoryStats() {
const response = await this.api.get('/inventory/stats');
return response.data;
}
// Orders endpoints
async getActiveOrders() {
const response = await this.api.get('/orders/active');
return response.data;
}
async updateOrderStatus(orderId: string, status: string) {
const response = await this.api.patch(`/orders/${orderId}/status`, { status });
return response.data;
}
}
export const apiService = new ApiService();
export default apiService;

View File

@ -0,0 +1,152 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Product, Sale, Customer } from '../types';
const KEYS = {
PRODUCTS: '@offline_products',
PENDING_SALES: '@pending_sales',
CUSTOMERS: '@offline_customers',
LAST_SYNC: '@last_sync',
OFFLINE_MODE: '@offline_mode',
};
interface PendingSale {
id: string;
items: Array<{ productId: string; quantity: number; unitPrice: number }>;
paymentMethod: string;
customerId?: string;
total: number;
createdAt: string;
synced: boolean;
}
class OfflineStorage {
// Products
async saveProducts(products: Product[]): Promise<void> {
await AsyncStorage.setItem(KEYS.PRODUCTS, JSON.stringify(products));
}
async getProducts(): Promise<Product[]> {
const data = await AsyncStorage.getItem(KEYS.PRODUCTS);
return data ? JSON.parse(data) : [];
}
async getProductByBarcode(barcode: string): Promise<Product | null> {
const products = await this.getProducts();
return products.find((p) => p.barcode === barcode) || null;
}
async updateProductStock(productId: string, quantitySold: number): Promise<void> {
const products = await this.getProducts();
const updatedProducts = products.map((p) =>
p.id === productId ? { ...p, stock: Math.max(0, p.stock - quantitySold) } : p
);
await this.saveProducts(updatedProducts);
}
// Pending Sales (offline sales to sync later)
async savePendingSale(sale: Omit<PendingSale, 'id' | 'createdAt' | 'synced'>): Promise<string> {
const pendingSales = await this.getPendingSales();
const newSale: PendingSale = {
...sale,
id: `offline_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
createdAt: new Date().toISOString(),
synced: false,
};
pendingSales.push(newSale);
await AsyncStorage.setItem(KEYS.PENDING_SALES, JSON.stringify(pendingSales));
// Update local stock
for (const item of sale.items) {
await this.updateProductStock(item.productId, item.quantity);
}
return newSale.id;
}
async getPendingSales(): Promise<PendingSale[]> {
const data = await AsyncStorage.getItem(KEYS.PENDING_SALES);
return data ? JSON.parse(data) : [];
}
async getUnsyncedSales(): Promise<PendingSale[]> {
const sales = await this.getPendingSales();
return sales.filter((s) => !s.synced);
}
async markSaleAsSynced(saleId: string): Promise<void> {
const sales = await this.getPendingSales();
const updatedSales = sales.map((s) =>
s.id === saleId ? { ...s, synced: true } : s
);
await AsyncStorage.setItem(KEYS.PENDING_SALES, JSON.stringify(updatedSales));
}
async removeSyncedSales(): Promise<void> {
const sales = await this.getPendingSales();
const unsyncedSales = sales.filter((s) => !s.synced);
await AsyncStorage.setItem(KEYS.PENDING_SALES, JSON.stringify(unsyncedSales));
}
// Customers
async saveCustomers(customers: Customer[]): Promise<void> {
await AsyncStorage.setItem(KEYS.CUSTOMERS, JSON.stringify(customers));
}
async getCustomers(): Promise<Customer[]> {
const data = await AsyncStorage.getItem(KEYS.CUSTOMERS);
return data ? JSON.parse(data) : [];
}
// Sync metadata
async setLastSync(timestamp: string): Promise<void> {
await AsyncStorage.setItem(KEYS.LAST_SYNC, timestamp);
}
async getLastSync(): Promise<string | null> {
return AsyncStorage.getItem(KEYS.LAST_SYNC);
}
// Offline mode
async setOfflineMode(enabled: boolean): Promise<void> {
await AsyncStorage.setItem(KEYS.OFFLINE_MODE, JSON.stringify(enabled));
}
async isOfflineMode(): Promise<boolean> {
const data = await AsyncStorage.getItem(KEYS.OFFLINE_MODE);
return data ? JSON.parse(data) : false;
}
// Clear all offline data
async clearAll(): Promise<void> {
await AsyncStorage.multiRemove([
KEYS.PRODUCTS,
KEYS.PENDING_SALES,
KEYS.CUSTOMERS,
KEYS.LAST_SYNC,
]);
}
// Get offline stats
async getOfflineStats(): Promise<{
productCount: number;
pendingSalesCount: number;
pendingSalesTotal: number;
lastSync: string | null;
}> {
const [products, pendingSales, lastSync] = await Promise.all([
this.getProducts(),
this.getUnsyncedSales(),
this.getLastSync(),
]);
return {
productCount: products.length,
pendingSalesCount: pendingSales.length,
pendingSalesTotal: pendingSales.reduce((sum, s) => sum + s.total, 0),
lastSync,
};
}
}
export const offlineStorage = new OfflineStorage();
export default offlineStorage;

109
src/types/index.ts Normal file
View File

@ -0,0 +1,109 @@
// User and Auth types
export interface User {
id: string;
name: string;
phone: string;
role: 'owner' | 'employee';
}
export interface Tenant {
id: string;
name: string;
slug: string;
businessType: string;
subscriptionStatus: string;
}
export interface AuthState {
user: User | null;
tenant: Tenant | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
// Product types
export interface Product {
id: string;
name: string;
description?: string;
sku?: string;
price: number;
cost?: number;
barcode?: string;
stock: number;
currentStock: number;
minStock: number;
categoryId?: string;
category?: Category;
imageUrl?: string;
isFavorite: boolean;
isActive: boolean;
}
export interface Category {
id: string;
name: string;
icon?: string;
color?: string;
}
// Sale types
export interface CartItem {
product: Product;
quantity: number;
subtotal: number;
}
export interface Sale {
id: string;
ticketNumber: string;
items: SaleItem[];
subtotal: number;
tax: number;
total: number;
paymentMethod: string;
status: 'pending' | 'completed' | 'cancelled';
createdAt: string;
}
export interface SaleItem {
id: string;
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}
// Customer types
export interface Customer {
id: string;
name: string;
phone?: string;
fiadoEnabled: boolean;
fiadoLimit: number;
currentFiadoBalance: number;
}
// Dashboard types
export interface DashboardStats {
totalSales: number;
totalRevenue: number;
totalTax: number;
avgTicket: number;
}
// API Response types
export interface ApiResponse<T> {
data: T;
message?: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
user: User;
tenant: Tenant;
}

6
tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}