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:
parent
25acb645bc
commit
a71600df2f
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
30
App.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,3 +0,0 @@
|
|||||||
# michangarrito-mobile-v2
|
|
||||||
|
|
||||||
Mobile de michangarrito - Workspace V2
|
|
||||||
61
app.json
Normal file
61
app.json
Normal 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
BIN
assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
index.ts
Normal file
8
index.ts
Normal 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
9621
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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
79
src/constants/theme.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
103
src/contexts/AuthContext.tsx
Normal file
103
src/contexts/AuthContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
178
src/contexts/OfflineSyncContext.tsx
Normal file
178
src/contexts/OfflineSyncContext.tsx
Normal 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
157
src/hooks/useOfflineSync.ts
Normal 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;
|
||||||
242
src/navigation/AppNavigator.tsx
Normal file
242
src/navigation/AppNavigator.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
314
src/screens/BarcodeScannerScreen.tsx
Normal file
314
src/screens/BarcodeScannerScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
847
src/screens/CustomersScreen.tsx
Normal file
847
src/screens/CustomersScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
313
src/screens/DashboardScreen.tsx
Normal file
313
src/screens/DashboardScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
712
src/screens/InventoryScreen.tsx
Normal file
712
src/screens/InventoryScreen.tsx
Normal 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
276
src/screens/LoginScreen.tsx
Normal 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
403
src/screens/MoreScreen.tsx
Normal 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
600
src/screens/POSScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
804
src/screens/ProductsScreen.tsx
Normal file
804
src/screens/ProductsScreen.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
592
src/screens/ReportsScreen.tsx
Normal file
592
src/screens/ReportsScreen.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
506
src/screens/SettingsScreen.tsx
Normal file
506
src/screens/SettingsScreen.tsx
Normal 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
209
src/services/api.ts
Normal 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;
|
||||||
152
src/services/offlineStorage.ts
Normal file
152
src/services/offlineStorage.ts
Normal 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
109
src/types/index.ts
Normal 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
6
tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user