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