[MIINVENTARIO-MOBILE] chore: Cleanup config files
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
eb718a95aa
commit
62a6a96bb8
@ -1,5 +0,0 @@
|
||||
# API
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3142/api/v1
|
||||
|
||||
# Environment
|
||||
EXPO_PUBLIC_ENV=development
|
||||
47
.eslintrc.js
47
.eslintrc.js
@ -1,47 +0,0 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
ignorePatterns: [
|
||||
'.eslintrc.js',
|
||||
'babel.config.js',
|
||||
'metro.config.js',
|
||||
'node_modules',
|
||||
'.expo',
|
||||
],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
};
|
||||
11
.prettierrc
11
.prettierrc
@ -1,11 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"jsxSingleQuote": false
|
||||
}
|
||||
55
app.json
55
app.json
@ -1,55 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "MiInventario",
|
||||
"slug": "miinventario",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./src/assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./src/assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.miinventario.app",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "MiInventario necesita acceso a la camara para grabar videos de tus anaqueles y generar inventario automatico.",
|
||||
"NSMicrophoneUsageDescription": "MiInventario necesita acceso al microfono para grabar videos."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./src/assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.miinventario.app",
|
||||
"permissions": [
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./src/assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Permite acceso a la camara para escanear inventario."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"scheme": "miinventario"
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
'react-native-reanimated/plugin',
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
root: ['./src'],
|
||||
alias: {
|
||||
'@': './src',
|
||||
'@screens': './src/screens',
|
||||
'@components': './src/components',
|
||||
'@hooks': './src/hooks',
|
||||
'@stores': './src/stores',
|
||||
'@services': './src/services',
|
||||
'@utils': './src/utils',
|
||||
'@types': './src/types',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
};
|
||||
@ -1,37 +0,0 @@
|
||||
module.exports = {
|
||||
preset: 'react-native',
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$',
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(react-native|@react-native|expo|@expo|expo-.*|@react-native-async-storage|zustand|react-native-.*|@react-navigation)/)',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^@services/(.*)$': '<rootDir>/src/services/$1',
|
||||
'^@stores/(.*)$': '<rootDir>/src/stores/$1',
|
||||
'^@components/(.*)$': '<rootDir>/src/components/$1',
|
||||
'^@hooks/(.*)$': '<rootDir>/src/hooks/$1',
|
||||
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
|
||||
'^@theme/(.*)$': '<rootDir>/src/theme/$1',
|
||||
'^@types/(.*)$': '<rootDir>/src/types/$1',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'node',
|
||||
collectCoverageFrom: [
|
||||
'src/stores/**/*.{ts,tsx}',
|
||||
'src/services/api/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/__tests__/**',
|
||||
'!src/**/__mocks__/**',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 70,
|
||||
functions: 70,
|
||||
lines: 70,
|
||||
statements: 70,
|
||||
},
|
||||
},
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
reporters: ['default', 'jest-junit'],
|
||||
};
|
||||
@ -1,71 +0,0 @@
|
||||
// Mock expo-secure-store
|
||||
jest.mock('expo-secure-store', () => ({
|
||||
getItemAsync: jest.fn(() => Promise.resolve(null)),
|
||||
setItemAsync: jest.fn(() => Promise.resolve()),
|
||||
deleteItemAsync: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
// Mock expo-router
|
||||
jest.mock('expo-router', () => ({
|
||||
useRouter: jest.fn(() => ({
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
back: jest.fn(),
|
||||
})),
|
||||
useLocalSearchParams: jest.fn(() => ({})),
|
||||
usePathname: jest.fn(() => '/'),
|
||||
useSegments: jest.fn(() => []),
|
||||
Stack: {
|
||||
Screen: jest.fn(() => null),
|
||||
},
|
||||
Tabs: {
|
||||
Screen: jest.fn(() => null),
|
||||
},
|
||||
Link: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
// Mock @react-native-async-storage/async-storage
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: jest.fn(() => Promise.resolve(null)),
|
||||
setItem: jest.fn(() => Promise.resolve()),
|
||||
removeItem: jest.fn(() => Promise.resolve()),
|
||||
clear: jest.fn(() => Promise.resolve()),
|
||||
getAllKeys: jest.fn(() => Promise.resolve([])),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-native-reanimated
|
||||
jest.mock('react-native-reanimated', () => {
|
||||
const Reanimated = require('react-native-reanimated/mock');
|
||||
Reanimated.default.call = () => {};
|
||||
return Reanimated;
|
||||
});
|
||||
|
||||
// Mock @react-native-community/netinfo
|
||||
jest.mock('@react-native-community/netinfo', () => ({
|
||||
addEventListener: jest.fn(() => jest.fn()),
|
||||
fetch: jest.fn(() => Promise.resolve({ isConnected: true })),
|
||||
}));
|
||||
|
||||
// Global fetch mock
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () => Promise.resolve({}),
|
||||
ok: true,
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
|
||||
// Console error suppression for known issues
|
||||
const originalError = console.error;
|
||||
console.error = (...args) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
(args[0].includes('Warning: ReactDOM.render') ||
|
||||
args[0].includes('Warning: An update to'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalError.call(console, ...args);
|
||||
};
|
||||
63
package.json
63
package.json
@ -1,63 +0,0 @@
|
||||
{
|
||||
"name": "@miinventario/mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"start:dev": "expo start --dev-client",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:ci": "jest --ci --coverage --reporters=default --reporters=jest-junit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.0",
|
||||
"@react-native-async-storage/async-storage": "1.21.0",
|
||||
"@react-native-community/netinfo": "11.1.0",
|
||||
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||
"@react-navigation/native": "^6.1.0",
|
||||
"@react-navigation/native-stack": "^6.9.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~50.0.0",
|
||||
"expo-av": "~13.10.0",
|
||||
"expo-camera": "~14.0.0",
|
||||
"expo-clipboard": "^8.0.8",
|
||||
"expo-file-system": "~16.0.0",
|
||||
"expo-image-picker": "~14.7.0",
|
||||
"expo-router": "~3.4.0",
|
||||
"expo-secure-store": "~12.8.0",
|
||||
"expo-splash-screen": "~0.26.0",
|
||||
"expo-status-bar": "~1.11.0",
|
||||
"react": "18.2.0",
|
||||
"react-hook-form": "^7.48.0",
|
||||
"react-native": "0.73.0",
|
||||
"react-native-gesture-handler": "~2.14.0",
|
||||
"react-native-reanimated": "~3.6.0",
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "~3.29.0",
|
||||
"zod": "^3.22.0",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@testing-library/react-native": "^12.0.0",
|
||||
"@types/react": "~18.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
export const mockApiClient = {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: {
|
||||
request: {
|
||||
use: jest.fn(),
|
||||
},
|
||||
response: {
|
||||
use: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const resetApiClientMocks = () => {
|
||||
mockApiClient.get.mockReset();
|
||||
mockApiClient.post.mockReset();
|
||||
mockApiClient.put.mockReset();
|
||||
mockApiClient.patch.mockReset();
|
||||
mockApiClient.delete.mockReset();
|
||||
};
|
||||
|
||||
export const mockApiResponse = <T>(data: T) => ({
|
||||
data,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: {},
|
||||
});
|
||||
|
||||
export const mockApiError = (
|
||||
message: string,
|
||||
status = 400,
|
||||
data: unknown = {}
|
||||
) => {
|
||||
const error = new Error(message) as Error & {
|
||||
response: { data: unknown; status: number };
|
||||
isAxiosError: boolean;
|
||||
};
|
||||
error.response = { data, status };
|
||||
error.isAxiosError = true;
|
||||
return error;
|
||||
};
|
||||
|
||||
export default mockApiClient;
|
||||
@ -1,16 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="register" />
|
||||
<Stack.Screen name="verify-otp" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native';
|
||||
import { Link, router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAuthStore } from '@stores/auth.store';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuthStore();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!phone || !password) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(phone, password);
|
||||
router.replace('/(tabs)');
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>MiInventario</Text>
|
||||
<Text style={styles.subtitle}>Inicia sesion para continuar</Text>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Telefono"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
keyboardType="phone-pad"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Contrasena"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Iniciando...' : 'Iniciar Sesion'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>No tienes cuenta? </Text>
|
||||
<Link href="/(auth)/register" asChild>
|
||||
<TouchableOpacity>
|
||||
<Text style={styles.link}>Registrate</Text>
|
||||
</TouchableOpacity>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
color: '#666',
|
||||
},
|
||||
form: {
|
||||
gap: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: 24,
|
||||
},
|
||||
footerText: {
|
||||
color: '#666',
|
||||
},
|
||||
link: {
|
||||
color: '#2563eb',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,136 +0,0 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native';
|
||||
import { Link, router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAuthStore } from '@stores/auth.store';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { initiateRegistration } = useAuthStore();
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!phone || !name) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await initiateRegistration(phone, name);
|
||||
router.push({
|
||||
pathname: '/(auth)/verify-otp',
|
||||
params: { phone },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Crear Cuenta</Text>
|
||||
<Text style={styles.subtitle}>Ingresa tus datos para registrarte</Text>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Nombre"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Telefono"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Enviando...' : 'Continuar'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Ya tienes cuenta? </Text>
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<TouchableOpacity>
|
||||
<Text style={styles.link}>Inicia Sesion</Text>
|
||||
</TouchableOpacity>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
color: '#666',
|
||||
},
|
||||
form: {
|
||||
gap: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: 24,
|
||||
},
|
||||
footerText: {
|
||||
color: '#666',
|
||||
},
|
||||
link: {
|
||||
color: '#2563eb',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,129 +0,0 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAuthStore } from '@stores/auth.store';
|
||||
|
||||
export default function VerifyOtpScreen() {
|
||||
const { phone } = useLocalSearchParams<{ phone: string }>();
|
||||
const [otp, setOtp] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { verifyOtp } = useAuthStore();
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!otp || !password || !phone) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await verifyOtp(phone, otp, password);
|
||||
router.replace('/(tabs)');
|
||||
} catch (error) {
|
||||
console.error('Verification error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Verificar Codigo</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Ingresa el codigo enviado a {phone}
|
||||
</Text>
|
||||
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Codigo OTP"
|
||||
value={otp}
|
||||
onChangeText={setOtp}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Crear Contrasena"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleVerify}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Verificando...' : 'Verificar y Crear Cuenta'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.resendButton}>
|
||||
<Text style={styles.resendText}>Reenviar codigo</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
color: '#666',
|
||||
},
|
||||
form: {
|
||||
gap: 16,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#2563eb',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
resendButton: {
|
||||
alignItems: 'center',
|
||||
marginTop: 24,
|
||||
},
|
||||
resendText: {
|
||||
color: '#2563eb',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@ -1,48 +0,0 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
export default function TabsLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: '#2563eb',
|
||||
tabBarInactiveTintColor: '#666',
|
||||
tabBarStyle: {
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
height: 60,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Inicio',
|
||||
tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>🏠</Text>,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="scan"
|
||||
options={{
|
||||
title: 'Escanear',
|
||||
tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>📷</Text>,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="inventory"
|
||||
options={{
|
||||
title: 'Inventario',
|
||||
tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>📦</Text>,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Perfil',
|
||||
tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>👤</Text>,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@ -1,542 +0,0 @@
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import Animated, { FadeIn, FadeInDown, FadeInRight, Layout } from 'react-native-reanimated';
|
||||
import { useAuthStore } from '@stores/auth.store';
|
||||
import { useCreditsStore } from '@stores/credits.store';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
import { useInventoryStore } from '@stores/inventory.store';
|
||||
import { useNotificationsStore } from '@stores/notifications.store';
|
||||
import { useFadeIn, usePressScale } from '../../hooks/useAnimations';
|
||||
import { Skeleton, SkeletonText, SkeletonStat } from '../../components/ui/Skeleton';
|
||||
|
||||
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
function ActionCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
index,
|
||||
}: {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
onPress: () => void;
|
||||
index: number;
|
||||
}) {
|
||||
const { animatedStyle, onPressIn, onPressOut } = usePressScale(0.98);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInRight.delay(200 + index * 100).duration(400)}
|
||||
layout={Layout.springify()}
|
||||
>
|
||||
<AnimatedTouchable
|
||||
style={[styles.actionCard, animatedStyle]}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<View style={styles.actionIconContainer}>
|
||||
<Text style={styles.actionIcon}>{icon}</Text>
|
||||
</View>
|
||||
<View style={styles.actionContent}>
|
||||
<Text style={styles.actionTitle}>{title}</Text>
|
||||
<Text style={styles.actionDescription}>{description}</Text>
|
||||
</View>
|
||||
<Text style={styles.actionArrow}>›</Text>
|
||||
</AnimatedTouchable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
value,
|
||||
label,
|
||||
index,
|
||||
}: {
|
||||
value: number;
|
||||
label: string;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<Animated.View
|
||||
style={styles.statCard}
|
||||
entering={FadeInDown.delay(400 + index * 100).duration(400)}
|
||||
>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeSkeleton() {
|
||||
return (
|
||||
<View style={styles.content}>
|
||||
{/* Header Skeleton */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<SkeletonText width={180} height={28} />
|
||||
<SkeletonText width={120} height={16} style={{ marginTop: 8 }} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Credits Card Skeleton */}
|
||||
<Skeleton width="100%" height={150} borderRadius={16} style={{ marginBottom: 20 }} />
|
||||
|
||||
{/* Actions Skeleton */}
|
||||
<SkeletonText width={140} height={18} style={{ marginBottom: 12 }} />
|
||||
<Skeleton width="100%" height={80} borderRadius={12} style={{ marginBottom: 12 }} />
|
||||
<Skeleton width="100%" height={80} borderRadius={12} style={{ marginBottom: 12 }} />
|
||||
<Skeleton width="100%" height={80} borderRadius={12} style={{ marginBottom: 20 }} />
|
||||
|
||||
{/* Stats Skeleton */}
|
||||
<SkeletonText width={100} height={18} style={{ marginBottom: 12 }} />
|
||||
<View style={styles.statsGrid}>
|
||||
<SkeletonStat />
|
||||
<SkeletonStat />
|
||||
<SkeletonStat />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user } = useAuthStore();
|
||||
const { balance, fetchBalance, isLoading: creditsLoading } = useCreditsStore();
|
||||
const { stores, currentStore, fetchStores, isLoading: storesLoading } = useStoresStore();
|
||||
const { items, fetchItems, isLoading: inventoryLoading } = useInventoryStore();
|
||||
const { unreadCount, fetchUnreadCount } = useNotificationsStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
await Promise.all([
|
||||
fetchBalance(),
|
||||
fetchStores(true),
|
||||
fetchUnreadCount(),
|
||||
]);
|
||||
setInitialLoad(false);
|
||||
}, [fetchBalance, fetchStores, fetchUnreadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStore) {
|
||||
fetchItems(currentStore.id, true);
|
||||
}
|
||||
}, [currentStore, fetchItems]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const isLoading = initialLoad && (creditsLoading || storesLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView style={styles.scroll}>
|
||||
<HomeSkeleton />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<Animated.View
|
||||
style={styles.header}
|
||||
entering={FadeIn.duration(400)}
|
||||
>
|
||||
<View style={styles.headerTop}>
|
||||
<View>
|
||||
<Text style={styles.greeting}>Hola, {user?.name || 'Usuario'}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{currentStore ? currentStore.name : 'Selecciona una tienda'}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.notificationButton}
|
||||
onPress={() => router.push('/notifications')}
|
||||
>
|
||||
<Text style={styles.notificationIcon}>🔔</Text>
|
||||
{unreadCount > 0 && (
|
||||
<View style={styles.notificationBadge}>
|
||||
<Text style={styles.notificationBadgeText}>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Credits Card */}
|
||||
<Animated.View
|
||||
style={styles.creditsCard}
|
||||
entering={FadeInDown.delay(100).duration(400)}
|
||||
>
|
||||
<View style={styles.creditsHeader}>
|
||||
<Text style={styles.creditsLabel}>Creditos disponibles</Text>
|
||||
<TouchableOpacity onPress={() => router.push('/credits/history')}>
|
||||
<Text style={styles.creditsHistoryLink}>Ver historial</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.creditsAmount}>{balance?.balance ?? 0}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.buyButton}
|
||||
onPress={() => router.push('/credits/buy')}
|
||||
>
|
||||
<Text style={styles.buyButtonText}>Comprar Creditos</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Store Selector */}
|
||||
{stores.length > 1 && (
|
||||
<Animated.View
|
||||
style={styles.storeSelector}
|
||||
entering={FadeInDown.delay(150).duration(400)}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>Tienda Activa</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
{stores.map((store, index) => (
|
||||
<Animated.View
|
||||
key={store.id}
|
||||
entering={FadeInRight.delay(200 + index * 50).duration(300)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.storeChip,
|
||||
currentStore?.id === store.id && styles.storeChipActive,
|
||||
]}
|
||||
onPress={() => useStoresStore.getState().selectStore(store)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.storeChipText,
|
||||
currentStore?.id === store.id && styles.storeChipTextActive,
|
||||
]}
|
||||
>
|
||||
{store.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<View style={styles.actionsSection}>
|
||||
<Animated.Text
|
||||
style={styles.sectionTitle}
|
||||
entering={FadeIn.delay(200).duration(300)}
|
||||
>
|
||||
Acciones Rapidas
|
||||
</Animated.Text>
|
||||
|
||||
<ActionCard
|
||||
icon="📷"
|
||||
title="Escanear Anaquel"
|
||||
description="Graba un video para actualizar tu inventario"
|
||||
onPress={() => router.push('/(tabs)/scan')}
|
||||
index={0}
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
icon="📦"
|
||||
title="Ver Inventario"
|
||||
description="Consulta y edita tu inventario actual"
|
||||
onPress={() => router.push('/(tabs)/inventory')}
|
||||
index={1}
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
icon="🎁"
|
||||
title="Invitar Amigos"
|
||||
description="Gana creditos por cada referido"
|
||||
onPress={() => router.push('/referrals')}
|
||||
index={2}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Stats */}
|
||||
<View style={styles.statsSection}>
|
||||
<Animated.Text
|
||||
style={styles.sectionTitle}
|
||||
entering={FadeIn.delay(350).duration(300)}
|
||||
>
|
||||
Resumen
|
||||
</Animated.Text>
|
||||
<View style={styles.statsGrid}>
|
||||
<StatCard value={stores.length} label="Tiendas" index={0} />
|
||||
<StatCard value={items.length} label="Productos" index={1} />
|
||||
<StatCard value={balance?.totalConsumed ?? 0} label="Escaneos" index={2} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Low Stock Alert */}
|
||||
{items.filter(i => i.quantity < 5).length > 0 && (
|
||||
<Animated.View
|
||||
style={styles.alertCard}
|
||||
entering={FadeInDown.delay(600).duration(400)}
|
||||
>
|
||||
<Text style={styles.alertIcon}>⚠️</Text>
|
||||
<View style={styles.alertContent}>
|
||||
<Text style={styles.alertTitle}>Stock Bajo</Text>
|
||||
<Text style={styles.alertDescription}>
|
||||
{items.filter(i => i.quantity < 5).length} productos con menos de 5 unidades
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => router.push('/(tabs)/inventory?filter=low-stock')}>
|
||||
<Text style={styles.alertAction}>Ver</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
headerTop: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
greeting: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
notificationButton: {
|
||||
position: 'relative',
|
||||
padding: 8,
|
||||
},
|
||||
notificationIcon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
notificationBadge: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
backgroundColor: '#ef4444',
|
||||
borderRadius: 10,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
notificationBadgeText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
creditsCard: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
creditsHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
creditsLabel: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 14,
|
||||
},
|
||||
creditsHistoryLink: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 14,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
creditsAmount: {
|
||||
color: '#fff',
|
||||
fontSize: 48,
|
||||
fontWeight: 'bold',
|
||||
marginVertical: 8,
|
||||
},
|
||||
buyButton: {
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
buyButtonText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
storeSelector: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
storeChip: {
|
||||
backgroundColor: '#fff',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
marginRight: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
},
|
||||
storeChipActive: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderColor: '#2563eb',
|
||||
},
|
||||
storeChipText: {
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
storeChipTextActive: {
|
||||
color: '#fff',
|
||||
},
|
||||
actionsSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 12,
|
||||
},
|
||||
actionCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
actionIconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f0f9ff',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
actionIcon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
actionContent: {
|
||||
flex: 1,
|
||||
},
|
||||
actionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
actionDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
actionArrow: {
|
||||
fontSize: 24,
|
||||
color: '#ccc',
|
||||
},
|
||||
statsSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
alertCard: {
|
||||
backgroundColor: '#fef3c7',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fcd34d',
|
||||
},
|
||||
alertIcon: {
|
||||
fontSize: 24,
|
||||
marginRight: 12,
|
||||
},
|
||||
alertContent: {
|
||||
flex: 1,
|
||||
},
|
||||
alertTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#92400e',
|
||||
},
|
||||
alertDescription: {
|
||||
fontSize: 12,
|
||||
color: '#a16207',
|
||||
marginTop: 2,
|
||||
},
|
||||
alertAction: {
|
||||
color: '#2563eb',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@ -1,554 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeIn, FadeInDown, FadeInRight, FadeOut, Layout } from 'react-native-reanimated';
|
||||
import { useInventoryStore } from '@stores/inventory.store';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
import { usePressScale } from '../../hooks/useAnimations';
|
||||
import { InventoryListSkeleton } from '../../components/skeletons/InventoryItemSkeleton';
|
||||
|
||||
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
category?: string;
|
||||
barcode?: string;
|
||||
price?: number;
|
||||
detectionConfidence?: number;
|
||||
isManuallyEdited?: boolean;
|
||||
}
|
||||
|
||||
function InventoryItemCard({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: InventoryItem;
|
||||
index: number;
|
||||
}) {
|
||||
const { animatedStyle, onPressIn, onPressOut } = usePressScale(0.98);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInRight.delay(Math.min(index * 50, 300)).duration(300)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
layout={Layout.springify()}
|
||||
>
|
||||
<AnimatedTouchable
|
||||
style={[styles.itemCard, animatedStyle]}
|
||||
onPress={() => router.push(`/inventory/${item.id}`)}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<View style={styles.itemInfo}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.itemName} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.isManuallyEdited && (
|
||||
<View style={styles.editedBadge}>
|
||||
<Text style={styles.editedBadgeText}>Editado</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.itemCategory}>{item.category || 'Sin categoria'}</Text>
|
||||
{item.barcode && (
|
||||
<Text style={styles.itemBarcode}>Codigo: {item.barcode}</Text>
|
||||
)}
|
||||
{item.detectionConfidence && (
|
||||
<View style={styles.confidenceContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.confidenceBar,
|
||||
{ width: `${item.detectionConfidence * 100}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.itemQuantity,
|
||||
item.quantity < 5 && styles.itemQuantityLow,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.quantityValue,
|
||||
item.quantity < 5 && styles.quantityValueLow,
|
||||
]}
|
||||
>
|
||||
{item.quantity}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.quantityLabel,
|
||||
item.quantity < 5 && styles.quantityLabelLow,
|
||||
]}
|
||||
>
|
||||
unidades
|
||||
</Text>
|
||||
</View>
|
||||
</AnimatedTouchable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InventoryScreen() {
|
||||
const { items, total, isLoading, error, fetchItems, searchQuery, setSearchQuery } =
|
||||
useInventoryStore();
|
||||
const { currentStore } = useStoresStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [filter, setFilter] = useState<'all' | 'low-stock'>('all');
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStore) {
|
||||
fetchItems(currentStore.id, true).then(() => setInitialLoad(false));
|
||||
}
|
||||
}, [currentStore, fetchItems]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (!currentStore) return;
|
||||
setRefreshing(true);
|
||||
await fetchItems(currentStore.id, true);
|
||||
setRefreshing(false);
|
||||
}, [currentStore, fetchItems]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
let result = items;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.category?.toLowerCase().includes(query) ||
|
||||
item.barcode?.includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply low stock filter
|
||||
if (filter === 'low-stock') {
|
||||
result = result.filter((item) => item.quantity < 5);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, searchQuery, filter]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: InventoryItem; index: number }) => (
|
||||
<InventoryItemCard item={item} index={index} />
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const EmptyState = () => (
|
||||
<Animated.View
|
||||
style={styles.emptyState}
|
||||
entering={FadeIn.delay(200).duration(400)}
|
||||
>
|
||||
<Text style={styles.emptyIcon}>📦</Text>
|
||||
<Text style={styles.emptyTitle}>
|
||||
{searchQuery ? 'Sin resultados' : 'Sin inventario'}
|
||||
</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
{searchQuery
|
||||
? `No se encontraron productos que coincidan con "${searchQuery}"`
|
||||
: 'Escanea tu primer anaquel para comenzar a registrar tu inventario'}
|
||||
</Text>
|
||||
{!searchQuery && (
|
||||
<TouchableOpacity
|
||||
style={styles.emptyButton}
|
||||
onPress={() => router.push('/(tabs)/scan')}
|
||||
>
|
||||
<Text style={styles.emptyButtonText}>Escanear Anaquel</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
if (!currentStore) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Animated.View
|
||||
style={styles.emptyState}
|
||||
entering={FadeIn.duration(400)}
|
||||
>
|
||||
<Text style={styles.emptyIcon}>🏪</Text>
|
||||
<Text style={styles.emptyTitle}>Sin tienda seleccionada</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
Crea o selecciona una tienda para ver su inventario
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.emptyButton}
|
||||
onPress={() => router.push('/stores/new')}
|
||||
>
|
||||
<Text style={styles.emptyButtonText}>Crear Tienda</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const showSkeleton = isLoading && initialLoad && items.length === 0;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<Animated.View
|
||||
style={styles.header}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<View style={styles.headerTop}>
|
||||
<View>
|
||||
<Text style={styles.title}>Inventario</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{currentStore.name} - {total} productos
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Search */}
|
||||
<Animated.View
|
||||
style={styles.searchContainer}
|
||||
entering={FadeInDown.delay(100).duration(300)}
|
||||
>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Buscar producto..."
|
||||
placeholderTextColor="#999"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<TouchableOpacity
|
||||
style={styles.clearSearch}
|
||||
onPress={() => setSearchQuery('')}
|
||||
>
|
||||
<Text style={styles.clearSearchText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Filters */}
|
||||
<Animated.View
|
||||
style={styles.filtersContainer}
|
||||
entering={FadeInDown.delay(150).duration(300)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterChip, filter === 'all' && styles.filterChipActive]}
|
||||
onPress={() => setFilter('all')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterChipText,
|
||||
filter === 'all' && styles.filterChipTextActive,
|
||||
]}
|
||||
>
|
||||
Todos
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.filterChip,
|
||||
filter === 'low-stock' && styles.filterChipActive,
|
||||
]}
|
||||
onPress={() => setFilter('low-stock')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterChipText,
|
||||
filter === 'low-stock' && styles.filterChipTextActive,
|
||||
]}
|
||||
>
|
||||
Stock bajo
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
{/* List */}
|
||||
{showSkeleton ? (
|
||||
<View style={styles.list}>
|
||||
<InventoryListSkeleton count={8} />
|
||||
</View>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor="#2563eb"
|
||||
colors={['#2563eb']}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Animated.View
|
||||
style={styles.errorBanner}
|
||||
entering={FadeInDown.duration(300)}
|
||||
>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity onPress={onRefresh}>
|
||||
<Text style={styles.errorRetry}>Reintentar</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
headerTop: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
searchContainer: {
|
||||
position: 'relative',
|
||||
marginTop: 16,
|
||||
},
|
||||
searchInput: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
clearSearch: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
top: 12,
|
||||
padding: 4,
|
||||
},
|
||||
clearSearchText: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
},
|
||||
filtersContainer: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 12,
|
||||
gap: 8,
|
||||
},
|
||||
filterChip: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
},
|
||||
filterChipActive: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderColor: '#2563eb',
|
||||
},
|
||||
filterChipText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
filterChipTextActive: {
|
||||
color: '#fff',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
itemCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
itemInfo: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
itemHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
itemName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
flex: 1,
|
||||
},
|
||||
editedBadge: {
|
||||
backgroundColor: '#dbeafe',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
editedBadgeText: {
|
||||
fontSize: 10,
|
||||
color: '#2563eb',
|
||||
fontWeight: '500',
|
||||
},
|
||||
itemCategory: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
itemBarcode: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 2,
|
||||
},
|
||||
confidenceContainer: {
|
||||
height: 4,
|
||||
backgroundColor: '#e5e5e5',
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
confidenceBar: {
|
||||
height: '100%',
|
||||
backgroundColor: '#22c55e',
|
||||
borderRadius: 2,
|
||||
},
|
||||
itemQuantity: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f0f9ff',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
minWidth: 70,
|
||||
},
|
||||
itemQuantityLow: {
|
||||
backgroundColor: '#fef2f2',
|
||||
},
|
||||
quantityValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#2563eb',
|
||||
},
|
||||
quantityValueLow: {
|
||||
color: '#ef4444',
|
||||
},
|
||||
quantityLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
quantityLabelLow: {
|
||||
color: '#ef4444',
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
},
|
||||
emptyButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
footerLoader: {
|
||||
paddingVertical: 16,
|
||||
},
|
||||
errorBanner: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#fef2f2',
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#fecaca',
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
flex: 1,
|
||||
},
|
||||
errorRetry: {
|
||||
color: '#2563eb',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,531 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
Share,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { router } from 'expo-router';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { useAuthStore } from '@stores/auth.store';
|
||||
import { useCreditsStore } from '@stores/credits.store';
|
||||
import { useReferralsStore } from '@stores/referrals.store';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { balance, fetchBalance, isLoading: creditsLoading } = useCreditsStore();
|
||||
const { stats, fetchStats, isLoading: referralsLoading } = useReferralsStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
await Promise.all([fetchBalance(), fetchStats()]);
|
||||
}, [fetchBalance, fetchStats]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadData();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
Alert.alert(
|
||||
'Cerrar Sesion',
|
||||
'Estas seguro que deseas cerrar sesion?',
|
||||
[
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Cerrar Sesion',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await logout();
|
||||
router.replace('/(auth)/login');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const copyReferralCode = async () => {
|
||||
if (stats?.referralCode) {
|
||||
await Clipboard.setStringAsync(stats.referralCode);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const shareReferralCode = async () => {
|
||||
if (stats?.referralCode) {
|
||||
try {
|
||||
await Share.share({
|
||||
message: `Te invito a usar MiInventario, la app que te ayuda a controlar tu inventario con solo grabar un video. Usa mi codigo ${stats.referralCode} y ambos ganaremos creditos gratis!`,
|
||||
});
|
||||
} catch {
|
||||
// User cancelled share
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const MenuItem = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
destructive = false,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
onPress: () => void;
|
||||
destructive?: boolean;
|
||||
}) => (
|
||||
<TouchableOpacity style={styles.menuItem} onPress={onPress}>
|
||||
<Text style={styles.menuIcon}>{icon}</Text>
|
||||
<Text style={[styles.menuLabel, destructive && styles.menuLabelDestructive]}>
|
||||
{label}
|
||||
</Text>
|
||||
{value && <Text style={styles.menuValue}>{value}</Text>}
|
||||
<Text style={styles.menuArrow}>›</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>
|
||||
{user?.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.name}>{user?.name || 'Usuario'}</Text>
|
||||
<Text style={styles.phone}>{user?.phone || ''}</Text>
|
||||
</View>
|
||||
|
||||
{/* Credits Card */}
|
||||
<View style={styles.creditsCard}>
|
||||
<View style={styles.creditsMain}>
|
||||
<Text style={styles.creditsLabel}>Tu Balance</Text>
|
||||
<Text style={styles.creditsAmount}>{balance?.balance ?? 0}</Text>
|
||||
<Text style={styles.creditsUnit}>creditos</Text>
|
||||
</View>
|
||||
<View style={styles.creditsStats}>
|
||||
<View style={styles.creditsStat}>
|
||||
<Text style={styles.creditsStatValue}>{balance?.totalPurchased ?? 0}</Text>
|
||||
<Text style={styles.creditsStatLabel}>Comprados</Text>
|
||||
</View>
|
||||
<View style={styles.creditsDivider} />
|
||||
<View style={styles.creditsStat}>
|
||||
<Text style={styles.creditsStatValue}>{balance?.totalFromReferrals ?? 0}</Text>
|
||||
<Text style={styles.creditsStatLabel}>Por referidos</Text>
|
||||
</View>
|
||||
<View style={styles.creditsDivider} />
|
||||
<View style={styles.creditsStat}>
|
||||
<Text style={styles.creditsStatValue}>{balance?.totalConsumed ?? 0}</Text>
|
||||
<Text style={styles.creditsStatLabel}>Usados</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.buyCreditsButton}
|
||||
onPress={() => router.push('/credits/buy')}
|
||||
>
|
||||
<Text style={styles.buyCreditsButtonText}>Comprar Creditos</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Referral Card */}
|
||||
<View style={styles.referralCard}>
|
||||
<Text style={styles.referralTitle}>Invita y Gana</Text>
|
||||
<Text style={styles.referralDescription}>
|
||||
Comparte tu codigo y gana 5 creditos por cada amigo que se registre
|
||||
</Text>
|
||||
<View style={styles.referralCodeContainer}>
|
||||
<Text style={styles.referralCodeLabel}>Tu codigo:</Text>
|
||||
<View style={styles.referralCodeBox}>
|
||||
<Text style={styles.referralCode}>{stats?.referralCode || '---'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.referralActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.referralActionButton}
|
||||
onPress={copyReferralCode}
|
||||
>
|
||||
<Text style={styles.referralActionIcon}>{copied ? '✓' : '📋'}</Text>
|
||||
<Text style={styles.referralActionText}>
|
||||
{copied ? 'Copiado!' : 'Copiar'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.referralActionButton, styles.referralActionButtonPrimary]}
|
||||
onPress={shareReferralCode}
|
||||
>
|
||||
<Text style={styles.referralActionIcon}>📤</Text>
|
||||
<Text style={[styles.referralActionText, styles.referralActionTextPrimary]}>
|
||||
Compartir
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.referralStats}>
|
||||
<View style={styles.referralStat}>
|
||||
<Text style={styles.referralStatValue}>{stats?.totalReferrals ?? 0}</Text>
|
||||
<Text style={styles.referralStatLabel}>Invitados</Text>
|
||||
</View>
|
||||
<View style={styles.referralStat}>
|
||||
<Text style={styles.referralStatValue}>{stats?.completedReferrals ?? 0}</Text>
|
||||
<Text style={styles.referralStatLabel}>Completados</Text>
|
||||
</View>
|
||||
<View style={styles.referralStat}>
|
||||
<Text style={styles.referralStatValue}>{stats?.totalCreditsEarned ?? 0}</Text>
|
||||
<Text style={styles.referralStatLabel}>Creditos ganados</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Menu Sections */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Cuenta</Text>
|
||||
<View style={styles.menuGroup}>
|
||||
<MenuItem
|
||||
icon="👤"
|
||||
label="Editar Perfil"
|
||||
onPress={() => router.push('/profile/edit')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="🏪"
|
||||
label="Mis Tiendas"
|
||||
onPress={() => router.push('/stores')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="💳"
|
||||
label="Metodos de Pago"
|
||||
onPress={() => router.push('/payments/methods')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Creditos</Text>
|
||||
<View style={styles.menuGroup}>
|
||||
<MenuItem
|
||||
icon="💰"
|
||||
label="Comprar Creditos"
|
||||
onPress={() => router.push('/credits/buy')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="📊"
|
||||
label="Historial de Transacciones"
|
||||
onPress={() => router.push('/credits/history')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="🎁"
|
||||
label="Mis Referidos"
|
||||
value={`${stats?.completedReferrals ?? 0} completados`}
|
||||
onPress={() => router.push('/referrals')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Soporte</Text>
|
||||
<View style={styles.menuGroup}>
|
||||
<MenuItem
|
||||
icon="❓"
|
||||
label="Centro de Ayuda"
|
||||
onPress={() => router.push('/help')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="📧"
|
||||
label="Contactar Soporte"
|
||||
onPress={() => router.push('/support')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="📝"
|
||||
label="Terminos y Condiciones"
|
||||
onPress={() => router.push('/legal/terms')}
|
||||
/>
|
||||
<MenuItem
|
||||
icon="🔒"
|
||||
label="Politica de Privacidad"
|
||||
onPress={() => router.push('/legal/privacy')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.menuGroup}>
|
||||
<MenuItem
|
||||
icon="🚪"
|
||||
label="Cerrar Sesion"
|
||||
onPress={handleLogout}
|
||||
destructive
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.version}>MiInventario v1.0.0</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 24,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#2563eb',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
phone: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
creditsCard: {
|
||||
backgroundColor: '#2563eb',
|
||||
margin: 16,
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 8,
|
||||
},
|
||||
creditsMain: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
creditsLabel: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 14,
|
||||
},
|
||||
creditsAmount: {
|
||||
color: '#fff',
|
||||
fontSize: 56,
|
||||
fontWeight: 'bold',
|
||||
marginVertical: 4,
|
||||
},
|
||||
creditsUnit: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 16,
|
||||
},
|
||||
creditsStats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255,255,255,0.2)',
|
||||
paddingTop: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
creditsStat: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
creditsDivider: {
|
||||
width: 1,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
creditsStatValue: {
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
creditsStatLabel: {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
buyCreditsButton: {
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buyCreditsButtonText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
},
|
||||
referralCard: {
|
||||
backgroundColor: '#fff',
|
||||
margin: 16,
|
||||
marginTop: 8,
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
referralTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
referralDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 16,
|
||||
},
|
||||
referralCodeContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
referralCodeLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
referralCodeBox: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
referralCode: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#2563eb',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
referralActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
referralActionButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f5f5f5',
|
||||
gap: 8,
|
||||
},
|
||||
referralActionButtonPrimary: {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
referralActionIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
referralActionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
referralActionTextPrimary: {
|
||||
color: '#fff',
|
||||
},
|
||||
referralStats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
paddingTop: 16,
|
||||
},
|
||||
referralStat: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
referralStatValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
referralStatLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
section: {
|
||||
marginTop: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 16,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
menuGroup: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: '#eee',
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
menuIcon: {
|
||||
fontSize: 20,
|
||||
marginRight: 12,
|
||||
},
|
||||
menuLabel: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
menuLabelDestructive: {
|
||||
color: '#ef4444',
|
||||
},
|
||||
menuValue: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginRight: 8,
|
||||
},
|
||||
menuArrow: {
|
||||
fontSize: 20,
|
||||
color: '#ccc',
|
||||
},
|
||||
version: {
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
fontSize: 14,
|
||||
marginVertical: 24,
|
||||
},
|
||||
});
|
||||
@ -1,467 +0,0 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Camera, CameraType, CameraRecordingOptions } from 'expo-camera';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { router } from 'expo-router';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { videosService } from '@services/api/videos.service';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
import { useCreditsStore } from '@stores/credits.store';
|
||||
|
||||
type ProcessingStatus = 'idle' | 'recording' | 'uploading' | 'processing' | 'completed' | 'failed';
|
||||
|
||||
export default function ScanScreen() {
|
||||
const [permission, requestPermission] = Camera.useCameraPermissions();
|
||||
const [audioPermission, requestAudioPermission] = Camera.useMicrophonePermissions();
|
||||
const [status, setStatus] = useState<ProcessingStatus>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const cameraRef = useRef<Camera>(null);
|
||||
const recordingTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const pollingTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { currentStore, fetchStores } = useStoresStore();
|
||||
const { fetchBalance } = useCreditsStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchStores();
|
||||
return () => {
|
||||
if (recordingTimer.current) clearInterval(recordingTimer.current);
|
||||
if (pollingTimer.current) clearInterval(pollingTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!permission || !audioPermission) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text style={styles.loadingText}>Cargando permisos...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!permission.granted || !audioPermission.granted) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.permissionText}>
|
||||
Necesitamos acceso a la camara y microfono para escanear tu inventario
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={async () => {
|
||||
await requestPermission();
|
||||
await requestAudioPermission();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>Dar Permisos</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentStore) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.permissionText}>
|
||||
Primero debes crear o seleccionar una tienda
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={() => router.push('/stores/new')}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>Crear Tienda</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const startRecording = async () => {
|
||||
if (!cameraRef.current) return;
|
||||
|
||||
setStatus('recording');
|
||||
setRecordingDuration(0);
|
||||
setErrorMessage(null);
|
||||
|
||||
// Start duration timer
|
||||
recordingTimer.current = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const options: CameraRecordingOptions = {
|
||||
maxDuration: 30, // Max 30 seconds
|
||||
};
|
||||
|
||||
const video = await cameraRef.current.recordAsync(options);
|
||||
|
||||
if (recordingTimer.current) {
|
||||
clearInterval(recordingTimer.current);
|
||||
}
|
||||
|
||||
await processVideo(video.uri);
|
||||
} catch (error) {
|
||||
console.error('Recording error:', error);
|
||||
setStatus('failed');
|
||||
setErrorMessage('Error al grabar video');
|
||||
if (recordingTimer.current) {
|
||||
clearInterval(recordingTimer.current);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
if (!cameraRef.current) return;
|
||||
|
||||
try {
|
||||
cameraRef.current.stopRecording();
|
||||
} catch (error) {
|
||||
console.error('Stop recording error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const processVideo = async (videoUri: string) => {
|
||||
if (!currentStore) return;
|
||||
|
||||
setStatus('uploading');
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(videoUri);
|
||||
const fileSize = (fileInfo as any).size || 0;
|
||||
const fileName = `scan_${Date.now()}.mp4`;
|
||||
|
||||
// Initiate upload
|
||||
const { videoId, uploadUrl } = await videosService.initiateUpload(
|
||||
currentStore.id,
|
||||
fileName,
|
||||
fileSize
|
||||
);
|
||||
|
||||
// Upload video
|
||||
await videosService.uploadVideo(uploadUrl, videoUri, (uploadProgress) => {
|
||||
setProgress(Math.round(uploadProgress * 50)); // 0-50% for upload
|
||||
});
|
||||
|
||||
// Confirm upload
|
||||
await videosService.confirmUpload(currentStore.id, videoId);
|
||||
|
||||
setStatus('processing');
|
||||
setProgress(50);
|
||||
|
||||
// Poll for processing status
|
||||
await pollProcessingStatus(currentStore.id, videoId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Processing error:', error);
|
||||
setStatus('failed');
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Error al procesar video');
|
||||
}
|
||||
};
|
||||
|
||||
const pollProcessingStatus = async (storeId: string, videoId: string) => {
|
||||
const maxAttempts = 60; // 2 minutes max
|
||||
let attempts = 0;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pollingTimer.current = setInterval(async () => {
|
||||
attempts++;
|
||||
|
||||
if (attempts > maxAttempts) {
|
||||
if (pollingTimer.current) clearInterval(pollingTimer.current);
|
||||
setStatus('failed');
|
||||
setErrorMessage('Tiempo de espera agotado');
|
||||
reject(new Error('Timeout'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await videosService.getStatus(storeId, videoId);
|
||||
|
||||
// Update progress (50-100% for processing)
|
||||
const processingProgress = 50 + (result.progress / 2);
|
||||
setProgress(Math.round(processingProgress));
|
||||
|
||||
if (result.status === 'completed') {
|
||||
if (pollingTimer.current) clearInterval(pollingTimer.current);
|
||||
setStatus('completed');
|
||||
setProgress(100);
|
||||
|
||||
// Refresh credits balance
|
||||
await fetchBalance();
|
||||
|
||||
// Show success and navigate
|
||||
Alert.alert(
|
||||
'Escaneo Completado',
|
||||
`Se detectaron ${result.resultItems || 0} productos`,
|
||||
[
|
||||
{
|
||||
text: 'Ver Inventario',
|
||||
onPress: () => router.replace('/(tabs)/inventory'),
|
||||
},
|
||||
{
|
||||
text: 'Nuevo Escaneo',
|
||||
onPress: () => resetState(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
resolve();
|
||||
} else if (result.status === 'failed') {
|
||||
if (pollingTimer.current) clearInterval(pollingTimer.current);
|
||||
setStatus('failed');
|
||||
setErrorMessage(result.errorMessage || 'Error al procesar');
|
||||
reject(new Error(result.errorMessage));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
setStatus('idle');
|
||||
setProgress(0);
|
||||
setRecordingDuration(0);
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const isRecording = status === 'recording';
|
||||
const isProcessing = status === 'uploading' || status === 'processing';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Camera
|
||||
ref={cameraRef}
|
||||
style={styles.camera}
|
||||
type={CameraType.back}
|
||||
>
|
||||
<SafeAreaView style={styles.overlay}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.storeName}>{currentStore.name}</Text>
|
||||
<Text style={styles.headerText}>
|
||||
{isRecording ? `Grabando ${formatDuration(recordingDuration)}` :
|
||||
isProcessing ? 'Procesando...' :
|
||||
status === 'completed' ? 'Completado' :
|
||||
status === 'failed' ? 'Error' :
|
||||
'Escanear Anaquel'}
|
||||
</Text>
|
||||
<Text style={styles.headerSubtext}>
|
||||
{isRecording ? 'Toca para detener' :
|
||||
isProcessing ? `${progress}% completado` :
|
||||
'Mueve la camara lentamente por el anaquel'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Progress bar for processing */}
|
||||
{isProcessing && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, { width: `${progress}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressText}>
|
||||
{status === 'uploading' ? 'Subiendo video...' : 'Detectando productos...'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{status === 'failed' && errorMessage && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={resetState}>
|
||||
<Text style={styles.retryButtonText}>Intentar de nuevo</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<View style={styles.controls}>
|
||||
{status === 'idle' && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.recordButton}
|
||||
onPress={startRecording}
|
||||
>
|
||||
<View style={styles.recordInner} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.recordText}>Iniciar Grabacion</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={[styles.recordButton, styles.recordButtonActive]}
|
||||
onPress={stopRecording}
|
||||
>
|
||||
<View style={[styles.recordInner, styles.recordInnerActive]} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.recordText}>Detener (max 30s)</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<ActivityIndicator size="large" color="#fff" />
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Camera>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
permissionText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
},
|
||||
permissionButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
},
|
||||
permissionButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
},
|
||||
storeName: {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerText: {
|
||||
color: '#fff',
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerSubtext: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
progressContainer: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
progressBar: {
|
||||
width: '80%',
|
||||
height: 8,
|
||||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#22c55e',
|
||||
borderRadius: 4,
|
||||
},
|
||||
progressText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: '#ef4444',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
controls: {
|
||||
alignItems: 'center',
|
||||
paddingBottom: 40,
|
||||
},
|
||||
recordButton: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 4,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
recordButtonActive: {
|
||||
borderColor: '#ef4444',
|
||||
},
|
||||
recordInner: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#ef4444',
|
||||
},
|
||||
recordInnerActive: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 4,
|
||||
},
|
||||
recordText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
marginTop: 12,
|
||||
},
|
||||
});
|
||||
@ -1,50 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { OfflineBanner } from '../components/ui/OfflineBanner';
|
||||
import { ThemeProvider } from '../theme/ThemeContext';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={styles.container}>
|
||||
<ThemeProvider>
|
||||
<SafeAreaProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<View style={styles.container}>
|
||||
<OfflineBanner />
|
||||
<StatusBar style="auto" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'slide_from_right',
|
||||
animationDuration: 250,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</View>
|
||||
</QueryClientProvider>
|
||||
</SafeAreaProvider>
|
||||
</ThemeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@ -1,23 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function CreditsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#fff' },
|
||||
headerTintColor: '#1a1a1a',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="buy"
|
||||
options={{ title: 'Comprar Creditos' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="history"
|
||||
options={{ title: 'Historial' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -1,434 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Linking,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { router } from 'expo-router';
|
||||
import { usePaymentsStore } from '@stores/payments.store';
|
||||
import { useCreditsStore } from '@stores/credits.store';
|
||||
import { CreditPackage } from '@services/api/payments.service';
|
||||
|
||||
type PaymentMethod = 'card' | 'oxxo' | '7eleven';
|
||||
|
||||
export default function BuyCreditsScreen() {
|
||||
const { packages, fetchPackages, createPayment, isLoading, isProcessing, error } =
|
||||
usePaymentsStore();
|
||||
const { fetchBalance } = useCreditsStore();
|
||||
const [selectedPackage, setSelectedPackage] = useState<CreditPackage | null>(null);
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>('oxxo');
|
||||
|
||||
useEffect(() => {
|
||||
fetchPackages();
|
||||
}, [fetchPackages]);
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!selectedPackage) {
|
||||
Alert.alert('Error', 'Selecciona un paquete de creditos');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await createPayment({
|
||||
packageId: selectedPackage.id,
|
||||
method: selectedMethod,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
if (response.status === 'completed') {
|
||||
await fetchBalance();
|
||||
Alert.alert(
|
||||
'Compra Exitosa',
|
||||
`Se agregaron ${selectedPackage.credits} creditos a tu cuenta`,
|
||||
[{ text: 'OK', onPress: () => router.back() }]
|
||||
);
|
||||
} else if (response.voucherUrl) {
|
||||
Alert.alert(
|
||||
'Pago Pendiente',
|
||||
`Tu ficha de pago esta lista. Codigo: ${response.voucherCode}\n\nTienes hasta ${new Date(response.expiresAt || '').toLocaleDateString()} para pagar.`,
|
||||
[
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Ver Ficha',
|
||||
onPress: () => Linking.openURL(response.voucherUrl!),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return `$${price.toFixed(2)} MXN`;
|
||||
};
|
||||
|
||||
const PaymentMethodButton = ({
|
||||
method,
|
||||
label,
|
||||
icon,
|
||||
description,
|
||||
}: {
|
||||
method: PaymentMethod;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.methodButton,
|
||||
selectedMethod === method && styles.methodButtonSelected,
|
||||
]}
|
||||
onPress={() => setSelectedMethod(method)}
|
||||
>
|
||||
<Text style={styles.methodIcon}>{icon}</Text>
|
||||
<View style={styles.methodInfo}>
|
||||
<Text
|
||||
style={[
|
||||
styles.methodLabel,
|
||||
selectedMethod === method && styles.methodLabelSelected,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={styles.methodDescription}>{description}</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.methodRadio,
|
||||
selectedMethod === method && styles.methodRadioSelected,
|
||||
]}
|
||||
>
|
||||
{selectedMethod === method && <View style={styles.methodRadioInner} />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (isLoading && packages.length === 0) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text style={styles.loadingText}>Cargando paquetes...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
{/* Packages */}
|
||||
<Text style={styles.sectionTitle}>Selecciona un paquete</Text>
|
||||
<View style={styles.packagesGrid}>
|
||||
{packages.map((pkg) => (
|
||||
<TouchableOpacity
|
||||
key={pkg.id}
|
||||
style={[
|
||||
styles.packageCard,
|
||||
selectedPackage?.id === pkg.id && styles.packageCardSelected,
|
||||
pkg.popular && styles.packageCardPopular,
|
||||
]}
|
||||
onPress={() => setSelectedPackage(pkg)}
|
||||
>
|
||||
{pkg.popular && (
|
||||
<View style={styles.popularBadge}>
|
||||
<Text style={styles.popularBadgeText}>Popular</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.packageCredits}>{pkg.credits}</Text>
|
||||
<Text style={styles.packageCreditsLabel}>creditos</Text>
|
||||
<Text style={styles.packagePrice}>{formatPrice(pkg.priceMXN)}</Text>
|
||||
<Text style={styles.packagePerCredit}>
|
||||
${(pkg.priceMXN / pkg.credits).toFixed(2)}/credito
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<Text style={styles.sectionTitle}>Metodo de pago</Text>
|
||||
<View style={styles.methodsContainer}>
|
||||
<PaymentMethodButton
|
||||
method="oxxo"
|
||||
label="OXXO"
|
||||
icon="🏪"
|
||||
description="Paga en efectivo en cualquier OXXO"
|
||||
/>
|
||||
<PaymentMethodButton
|
||||
method="7eleven"
|
||||
label="7-Eleven"
|
||||
icon="🏬"
|
||||
description="Paga en efectivo en cualquier 7-Eleven"
|
||||
/>
|
||||
<PaymentMethodButton
|
||||
method="card"
|
||||
label="Tarjeta"
|
||||
icon="💳"
|
||||
description="Debito o credito (Visa, Mastercard)"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoIcon}>ℹ️</Text>
|
||||
<Text style={styles.infoText}>
|
||||
{selectedMethod === 'card'
|
||||
? 'El pago con tarjeta se procesa inmediatamente y los creditos se agregan al instante.'
|
||||
: 'Recibiras una ficha de pago. Los creditos se agregan automaticamente al pagar.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={styles.errorCard}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
{selectedPackage && (
|
||||
<View style={styles.footerSummary}>
|
||||
<Text style={styles.footerLabel}>Total a pagar:</Text>
|
||||
<Text style={styles.footerPrice}>
|
||||
{formatPrice(selectedPackage.priceMXN)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.purchaseButton,
|
||||
(!selectedPackage || isProcessing) && styles.purchaseButtonDisabled,
|
||||
]}
|
||||
onPress={handlePurchase}
|
||||
disabled={!selectedPackage || isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.purchaseButtonText}>
|
||||
{selectedMethod === 'card' ? 'Pagar Ahora' : 'Generar Ficha de Pago'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
packagesGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
},
|
||||
packageCard: {
|
||||
width: '47%',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
packageCardSelected: {
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: '#f0f9ff',
|
||||
},
|
||||
packageCardPopular: {
|
||||
borderColor: '#f59e0b',
|
||||
},
|
||||
popularBadge: {
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
backgroundColor: '#f59e0b',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
},
|
||||
popularBadgeText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
packageCredits: {
|
||||
fontSize: 36,
|
||||
fontWeight: 'bold',
|
||||
color: '#2563eb',
|
||||
},
|
||||
packageCreditsLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
packagePrice: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
packagePerCredit: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 4,
|
||||
},
|
||||
methodsContainer: {
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
},
|
||||
methodButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
methodButtonSelected: {
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: '#f0f9ff',
|
||||
},
|
||||
methodIcon: {
|
||||
fontSize: 28,
|
||||
marginRight: 12,
|
||||
},
|
||||
methodInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
methodLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
methodLabelSelected: {
|
||||
color: '#2563eb',
|
||||
},
|
||||
methodDescription: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
methodRadio: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: '#ccc',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
methodRadioSelected: {
|
||||
borderColor: '#2563eb',
|
||||
},
|
||||
methodRadioInner: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
infoCard: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#f0f9ff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#bfdbfe',
|
||||
},
|
||||
infoIcon: {
|
||||
fontSize: 20,
|
||||
marginRight: 12,
|
||||
},
|
||||
infoText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#1e40af',
|
||||
lineHeight: 20,
|
||||
},
|
||||
errorCard: {
|
||||
backgroundColor: '#fef2f2',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fecaca',
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 14,
|
||||
},
|
||||
footer: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
},
|
||||
footerSummary: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
footerLabel: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
footerPrice: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
purchaseButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
purchaseButtonDisabled: {
|
||||
backgroundColor: '#93c5fd',
|
||||
},
|
||||
purchaseButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,221 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useCreditsStore } from '@stores/credits.store';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function CreditsHistoryScreen() {
|
||||
const {
|
||||
transactions,
|
||||
transactionsHasMore,
|
||||
fetchTransactions,
|
||||
isLoading,
|
||||
} = useCreditsStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransactions(true);
|
||||
}, [fetchTransactions]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await fetchTransactions(true);
|
||||
setRefreshing(false);
|
||||
}, [fetchTransactions]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (transactionsHasMore && !isLoading) {
|
||||
fetchTransactions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'purchase':
|
||||
return '💰';
|
||||
case 'consumption':
|
||||
return '📷';
|
||||
case 'referral_bonus':
|
||||
return '🎁';
|
||||
default:
|
||||
return '📝';
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionColor = (type: string, amount: number) => {
|
||||
if (amount > 0) return '#22c55e';
|
||||
return '#ef4444';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Transaction }) => (
|
||||
<View style={styles.transactionCard}>
|
||||
<View style={styles.transactionIcon}>
|
||||
<Text style={styles.transactionIconText}>
|
||||
{getTransactionIcon(item.type)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.transactionInfo}>
|
||||
<Text style={styles.transactionDescription}>{item.description}</Text>
|
||||
<Text style={styles.transactionDate}>{formatDate(item.createdAt)}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.transactionAmount,
|
||||
{ color: getTransactionColor(item.type, item.amount) },
|
||||
]}
|
||||
>
|
||||
{item.amount > 0 ? '+' : ''}{item.amount}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const EmptyState = () => (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>📋</Text>
|
||||
<Text style={styles.emptyTitle}>Sin transacciones</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
Aqui veras tu historial de creditos comprados y utilizados
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isLoading && transactions.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text style={styles.loadingText}>Cargando historial...</Text>
|
||||
</View>
|
||||
) : transactions.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<FlatList
|
||||
data={transactions}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
isLoading ? (
|
||||
<ActivityIndicator style={styles.footerLoader} color="#2563eb" />
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
transactionCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
transactionIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#f5f5f5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
transactionIconText: {
|
||||
fontSize: 20,
|
||||
},
|
||||
transactionInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
transactionDescription: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
transactionDate: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
transactionAmount: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
separator: {
|
||||
height: 8,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
footerLoader: {
|
||||
paddingVertical: 16,
|
||||
},
|
||||
});
|
||||
@ -1,292 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Stack, router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface FAQItem {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const faqs: FAQItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
question: 'Como escaneo mi inventario?',
|
||||
answer: 'Ve a la pestana "Escanear" y graba un video moviendo tu telefono lentamente por los anaqueles. La IA detectara automaticamente los productos y los agregara a tu inventario.',
|
||||
category: 'escaneo',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
question: 'Cuantos creditos necesito por escaneo?',
|
||||
answer: 'Cada escaneo de video consume 1 credito. Al registrarte recibes 5 creditos gratis para que pruebes la app.',
|
||||
category: 'creditos',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
question: 'Como compro mas creditos?',
|
||||
answer: 'Ve a tu perfil y toca "Comprar Creditos". Puedes pagar con tarjeta de credito/debito o en efectivo en OXXO.',
|
||||
category: 'creditos',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
question: 'Como gano creditos gratis?',
|
||||
answer: 'Invita a tus amigos usando tu codigo de referido. Por cada amigo que se registre, ambos reciben 5 creditos gratis.',
|
||||
category: 'creditos',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
question: 'Como creo una tienda?',
|
||||
answer: 'Ve a la pestana "Tiendas" y toca el boton "Nueva Tienda". Llena los datos de tu negocio y listo.',
|
||||
category: 'tiendas',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
question: 'Puedo tener varias tiendas?',
|
||||
answer: 'Si, puedes crear multiples tiendas y cambiar entre ellas. Cada tienda tiene su propio inventario.',
|
||||
category: 'tiendas',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
question: 'Como edito mi inventario?',
|
||||
answer: 'En la pestana "Inventario" puedes ver todos los productos detectados. Toca cualquier producto para editar su cantidad, precio o nombre.',
|
||||
category: 'inventario',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
question: 'Que tan precisa es la deteccion?',
|
||||
answer: 'La IA tiene una precision del 90-95%. Te recomendamos revisar los productos detectados y hacer ajustes si es necesario.',
|
||||
category: 'escaneo',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
question: 'El pago en OXXO es seguro?',
|
||||
answer: 'Si, utilizamos Stripe para procesar todos los pagos de forma segura. Al elegir OXXO recibiras un codigo para pagar en cualquier tienda.',
|
||||
category: 'pagos',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
question: 'Cuando recibo mis creditos al pagar en OXXO?',
|
||||
answer: 'Los creditos se acreditan automaticamente entre 24-48 horas despues de realizar el pago en tienda.',
|
||||
category: 'pagos',
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ id: 'todos', label: 'Todos' },
|
||||
{ id: 'escaneo', label: 'Escaneo' },
|
||||
{ id: 'creditos', label: 'Creditos' },
|
||||
{ id: 'tiendas', label: 'Tiendas' },
|
||||
{ id: 'inventario', label: 'Inventario' },
|
||||
{ id: 'pagos', label: 'Pagos' },
|
||||
];
|
||||
|
||||
export default function HelpScreen() {
|
||||
const [selectedCategory, setSelectedCategory] = useState('todos');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const filteredFaqs =
|
||||
selectedCategory === 'todos'
|
||||
? faqs
|
||||
: faqs.filter((faq) => faq.category === selectedCategory);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedId(expandedId === id ? null : id);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Centro de Ayuda',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
<ScrollView style={styles.scroll}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Como podemos ayudarte?</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Encuentra respuestas a las preguntas mas frecuentes
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.categoriesContainer}
|
||||
contentContainerStyle={styles.categoriesContent}
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
selectedCategory === category.id && styles.categoryChipActive,
|
||||
]}
|
||||
onPress={() => setSelectedCategory(category.id)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.categoryChipText,
|
||||
selectedCategory === category.id && styles.categoryChipTextActive,
|
||||
]}
|
||||
>
|
||||
{category.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.faqsList}>
|
||||
{filteredFaqs.map((faq) => (
|
||||
<TouchableOpacity
|
||||
key={faq.id}
|
||||
style={styles.faqItem}
|
||||
onPress={() => toggleExpand(faq.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.faqHeader}>
|
||||
<Text style={styles.faqQuestion}>{faq.question}</Text>
|
||||
<Text style={styles.faqArrow}>
|
||||
{expandedId === faq.id ? '−' : '+'}
|
||||
</Text>
|
||||
</View>
|
||||
{expandedId === faq.id && (
|
||||
<Text style={styles.faqAnswer}>{faq.answer}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.supportSection}>
|
||||
<Text style={styles.supportTitle}>No encontraste lo que buscabas?</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.supportButton}
|
||||
onPress={() => router.push('/support')}
|
||||
>
|
||||
<Text style={styles.supportButtonText}>Contactar Soporte</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
},
|
||||
categoriesContainer: {
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
categoriesContent: {
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
},
|
||||
categoryChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#f5f5f5',
|
||||
marginRight: 8,
|
||||
},
|
||||
categoryChipActive: {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
categoryChipText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
categoryChipTextActive: {
|
||||
color: '#fff',
|
||||
},
|
||||
faqsList: {
|
||||
padding: 16,
|
||||
gap: 8,
|
||||
},
|
||||
faqItem: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
faqHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
faqQuestion: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginRight: 12,
|
||||
},
|
||||
faqArrow: {
|
||||
fontSize: 20,
|
||||
color: '#2563eb',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
faqAnswer: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
supportSection: {
|
||||
margin: 16,
|
||||
padding: 20,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
supportTitle: {
|
||||
fontSize: 16,
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
supportButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
supportButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
@ -1,12 +0,0 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
import { useAuthStore } from '@stores/auth.store';
|
||||
|
||||
export default function Index() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Redirect href="/(tabs)" />;
|
||||
}
|
||||
|
||||
return <Redirect href="/(auth)/login" />;
|
||||
}
|
||||
@ -1,603 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { router, useLocalSearchParams, Stack } from 'expo-router';
|
||||
import { useInventoryStore } from '@stores/inventory.store';
|
||||
import { InventoryItem } from '@services/api/inventory.service';
|
||||
|
||||
export default function InventoryDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { items, updateItem, deleteItem, isLoading, error } = useInventoryStore();
|
||||
const [item, setItem] = useState<InventoryItem | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [quantity, setQuantity] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [barcode, setBarcode] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const foundItem = items.find((i) => i.id === id);
|
||||
if (foundItem) {
|
||||
setItem(foundItem);
|
||||
setName(foundItem.name);
|
||||
setQuantity(foundItem.quantity.toString());
|
||||
setCategory(foundItem.category || '');
|
||||
setBarcode(foundItem.barcode || '');
|
||||
setPrice(foundItem.price?.toString() || '');
|
||||
}
|
||||
}, [id, items]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Error', 'El nombre del producto es requerido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await updateItem(id, {
|
||||
name: name.trim(),
|
||||
quantity: parseInt(quantity, 10) || 0,
|
||||
category: category.trim() || undefined,
|
||||
barcode: barcode.trim() || undefined,
|
||||
price: price ? parseFloat(price) : undefined,
|
||||
});
|
||||
|
||||
Alert.alert('Listo', 'El producto ha sido actualizado');
|
||||
setIsEditing(false);
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Eliminar Producto',
|
||||
'Estas seguro de eliminar este producto del inventario?',
|
||||
[
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Eliminar',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await deleteItem(id);
|
||||
Alert.alert('Listo', 'El producto ha sido eliminado', [
|
||||
{ text: 'OK', onPress: () => router.back() },
|
||||
]);
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const adjustQuantity = (delta: number) => {
|
||||
const current = parseInt(quantity, 10) || 0;
|
||||
const newValue = Math.max(0, current + delta);
|
||||
setQuantity(newValue.toString());
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: isEditing ? 'Editar Producto' : 'Detalle del Producto',
|
||||
headerRight: () =>
|
||||
!isEditing ? (
|
||||
<TouchableOpacity
|
||||
style={styles.editHeaderButton}
|
||||
onPress={() => setIsEditing(true)}
|
||||
>
|
||||
<Text style={styles.editHeaderButtonText}>Editar</Text>
|
||||
</TouchableOpacity>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoid}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Quantity Card */}
|
||||
<View style={styles.quantityCard}>
|
||||
<Text style={styles.quantityLabel}>Cantidad en inventario</Text>
|
||||
{isEditing ? (
|
||||
<View style={styles.quantityEditor}>
|
||||
<TouchableOpacity
|
||||
style={styles.quantityButton}
|
||||
onPress={() => adjustQuantity(-1)}
|
||||
>
|
||||
<Text style={styles.quantityButtonText}>-</Text>
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
style={styles.quantityInput}
|
||||
value={quantity}
|
||||
onChangeText={setQuantity}
|
||||
keyboardType="number-pad"
|
||||
textAlign="center"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.quantityButton}
|
||||
onPress={() => adjustQuantity(1)}
|
||||
>
|
||||
<Text style={styles.quantityButtonText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.quantityValue,
|
||||
item.quantity < 5 && styles.quantityValueLow,
|
||||
]}
|
||||
>
|
||||
{item.quantity}
|
||||
</Text>
|
||||
)}
|
||||
{item.quantity < 5 && !isEditing && (
|
||||
<View style={styles.lowStockBadge}>
|
||||
<Text style={styles.lowStockBadgeText}>Stock bajo</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Details */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Informacion del producto</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>Nombre</Text>
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={styles.fieldInput}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Nombre del producto"
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.fieldValue}>{item.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>Categoria</Text>
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={styles.fieldInput}
|
||||
value={category}
|
||||
onChangeText={setCategory}
|
||||
placeholder="Ej: Abarrotes, Bebidas..."
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.fieldValue}>
|
||||
{item.category || 'Sin categoria'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>Codigo de barras</Text>
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={styles.fieldInput}
|
||||
value={barcode}
|
||||
onChangeText={setBarcode}
|
||||
placeholder="Codigo de barras"
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.fieldValue}>
|
||||
{item.barcode || 'Sin codigo'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>Precio</Text>
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={styles.fieldInput}
|
||||
value={price}
|
||||
onChangeText={setPrice}
|
||||
placeholder="0.00"
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.fieldValue}>
|
||||
{item.price ? `$${item.price.toFixed(2)}` : 'Sin precio'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Detection Info */}
|
||||
{item.detectionConfidence && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Deteccion automatica</Text>
|
||||
<View style={styles.detectionCard}>
|
||||
<View style={styles.detectionRow}>
|
||||
<Text style={styles.detectionLabel}>Confianza</Text>
|
||||
<Text style={styles.detectionValue}>
|
||||
{(item.detectionConfidence * 100).toFixed(0)}%
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.confidenceBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.confidenceBarFill,
|
||||
{ width: `${item.detectionConfidence * 100}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{item.isManuallyEdited && (
|
||||
<View style={styles.editedBadge}>
|
||||
<Text style={styles.editedBadgeText}>
|
||||
Editado manualmente
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Historial</Text>
|
||||
<View style={styles.metaCard}>
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>Creado</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{formatDate(item.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>Actualizado</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{formatDate(item.updatedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
{item.lastDetectedAt && (
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.metaLabel}>Ultima deteccion</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{formatDate(item.lastDetectedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={styles.errorCard}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Delete Button */}
|
||||
{isEditing && (
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<Text style={styles.deleteButtonText}>Eliminar Producto</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
{isEditing && (
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={() => {
|
||||
setName(item.name);
|
||||
setQuantity(item.quantity.toString());
|
||||
setCategory(item.category || '');
|
||||
setBarcode(item.barcode || '');
|
||||
setPrice(item.price?.toString() || '');
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancelar</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.saveButton,
|
||||
(!name.trim() || isLoading) && styles.saveButtonDisabled,
|
||||
]}
|
||||
onPress={handleSave}
|
||||
disabled={!name.trim() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>Guardar</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
keyboardAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
editHeaderButton: {
|
||||
marginRight: 8,
|
||||
},
|
||||
editHeaderButtonText: {
|
||||
color: '#2563eb',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
quantityCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
quantityLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
quantityValue: {
|
||||
fontSize: 64,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
quantityValueLow: {
|
||||
color: '#ef4444',
|
||||
},
|
||||
quantityEditor: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
quantityButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#2563eb',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
quantityButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
quantityInput: {
|
||||
width: 100,
|
||||
fontSize: 48,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
lowStockBadge: {
|
||||
backgroundColor: '#fef2f2',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
lowStockBadgeText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
section: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
field: {
|
||||
backgroundColor: '#fff',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f5f5f5',
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
fieldValue: {
|
||||
fontSize: 16,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
fieldInput: {
|
||||
fontSize: 16,
|
||||
color: '#1a1a1a',
|
||||
padding: 0,
|
||||
},
|
||||
detectionCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
detectionRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
detectionLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
detectionValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#22c55e',
|
||||
},
|
||||
confidenceBar: {
|
||||
height: 8,
|
||||
backgroundColor: '#e5e5e5',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
confidenceBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#22c55e',
|
||||
borderRadius: 4,
|
||||
},
|
||||
editedBadge: {
|
||||
backgroundColor: '#dbeafe',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
alignSelf: 'flex-start',
|
||||
marginTop: 12,
|
||||
},
|
||||
editedBadgeText: {
|
||||
color: '#2563eb',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
metaCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f5f5f5',
|
||||
},
|
||||
metaLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
metaValue: {
|
||||
fontSize: 14,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
errorCard: {
|
||||
backgroundColor: '#fef2f2',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fecaca',
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 14,
|
||||
},
|
||||
deleteButton: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: '#666',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
saveButton: {
|
||||
flex: 1,
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: '#93c5fd',
|
||||
},
|
||||
saveButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,23 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function InventoryLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#fff' },
|
||||
headerTintColor: '#1a1a1a',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="[id]"
|
||||
options={{ title: 'Detalle del Producto' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="export"
|
||||
options={{ title: 'Exportar Inventario' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -1,492 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useState } from 'react';
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
import {
|
||||
exportsService,
|
||||
ExportFormat,
|
||||
ExportStatusResponse,
|
||||
} from '@services/api/exports.service';
|
||||
|
||||
type ExportStep = 'select' | 'processing' | 'complete' | 'error';
|
||||
|
||||
export default function ExportInventoryScreen() {
|
||||
const { currentStore } = useStoresStore();
|
||||
const [format, setFormat] = useState<ExportFormat>('CSV');
|
||||
const [lowStockOnly, setLowStockOnly] = useState(false);
|
||||
const [step, setStep] = useState<ExportStep>('select');
|
||||
const [progress, setProgress] = useState<ExportStatusResponse | null>(null);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [filename, setFilename] = useState<string>('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!currentStore) {
|
||||
Alert.alert('Error', 'No hay tienda seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('processing');
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// Request export
|
||||
const { jobId } = await exportsService.requestInventoryExport(
|
||||
currentStore.id,
|
||||
format,
|
||||
lowStockOnly ? { lowStockOnly: true } : undefined,
|
||||
);
|
||||
|
||||
// Poll for completion
|
||||
const status = await exportsService.pollExportStatus(
|
||||
currentStore.id,
|
||||
jobId,
|
||||
(s) => setProgress(s),
|
||||
);
|
||||
|
||||
if (status.status === 'FAILED') {
|
||||
setStep('error');
|
||||
setErrorMessage(status.errorMessage || 'Error desconocido');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get download URL
|
||||
const download = await exportsService.getDownloadUrl(currentStore.id, jobId);
|
||||
setDownloadUrl(download.url);
|
||||
setFilename(download.filename);
|
||||
setStep('complete');
|
||||
} catch (error) {
|
||||
setStep('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Error al exportar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!downloadUrl) return;
|
||||
|
||||
try {
|
||||
await Linking.openURL(downloadUrl);
|
||||
} catch {
|
||||
Alert.alert('Error', 'No se pudo abrir el enlace de descarga');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!downloadUrl || !filename) return;
|
||||
|
||||
try {
|
||||
// Download file first
|
||||
const localUri = FileSystem.documentDirectory + filename;
|
||||
const download = await FileSystem.downloadAsync(downloadUrl, localUri);
|
||||
|
||||
// Share
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(download.uri);
|
||||
} else {
|
||||
Alert.alert('Error', 'Compartir no esta disponible en este dispositivo');
|
||||
}
|
||||
} catch {
|
||||
Alert.alert('Error', 'No se pudo compartir el archivo');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStep('select');
|
||||
setProgress(null);
|
||||
setDownloadUrl(null);
|
||||
setFilename('');
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
const renderFormatOption = (value: ExportFormat, label: string, description: string) => (
|
||||
<TouchableOpacity
|
||||
key={value}
|
||||
style={[styles.optionCard, format === value && styles.optionCardSelected]}
|
||||
onPress={() => setFormat(value)}
|
||||
>
|
||||
<View style={styles.optionHeader}>
|
||||
<View style={[styles.radio, format === value && styles.radioSelected]}>
|
||||
{format === value && <View style={styles.radioInner} />}
|
||||
</View>
|
||||
<Text style={styles.optionLabel}>{label}</Text>
|
||||
</View>
|
||||
<Text style={styles.optionDescription}>{description}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (step === 'processing') {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.centerContent}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text style={styles.processingTitle}>Generando exportacion...</Text>
|
||||
{progress && (
|
||||
<Text style={styles.processingStatus}>
|
||||
Estado: {progress.status}
|
||||
{progress.totalRows !== undefined && ` (${progress.totalRows} productos)`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'complete') {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.centerContent}>
|
||||
<View style={styles.successIcon}>
|
||||
<Text style={styles.successIconText}>✓</Text>
|
||||
</View>
|
||||
<Text style={styles.successTitle}>Exportacion lista</Text>
|
||||
<Text style={styles.successFilename}>{filename}</Text>
|
||||
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity style={styles.primaryButton} onPress={handleDownload}>
|
||||
<Text style={styles.primaryButtonText}>Descargar</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.secondaryButton} onPress={handleShare}>
|
||||
<Text style={styles.secondaryButtonText}>Compartir</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.linkButton} onPress={handleReset}>
|
||||
<Text style={styles.linkButtonText}>Nueva exportacion</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'error') {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.centerContent}>
|
||||
<View style={styles.errorIcon}>
|
||||
<Text style={styles.errorIconText}>!</Text>
|
||||
</View>
|
||||
<Text style={styles.errorTitle}>Error al exportar</Text>
|
||||
<Text style={styles.errorMessage}>{errorMessage}</Text>
|
||||
|
||||
<TouchableOpacity style={styles.primaryButton} onPress={handleReset}>
|
||||
<Text style={styles.primaryButtonText}>Intentar de nuevo</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.sectionTitle}>Formato de exportacion</Text>
|
||||
{renderFormatOption(
|
||||
'CSV',
|
||||
'CSV',
|
||||
'Archivo de texto separado por comas. Compatible con Excel, Google Sheets y otros.',
|
||||
)}
|
||||
{renderFormatOption(
|
||||
'EXCEL',
|
||||
'Excel (.xlsx)',
|
||||
'Archivo de Excel con formato y estilos. Ideal para reportes profesionales.',
|
||||
)}
|
||||
|
||||
<Text style={[styles.sectionTitle, styles.sectionTitleMargin]}>Filtros</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.checkboxRow}
|
||||
onPress={() => setLowStockOnly(!lowStockOnly)}
|
||||
>
|
||||
<View style={[styles.checkbox, lowStockOnly && styles.checkboxChecked]}>
|
||||
{lowStockOnly && <Text style={styles.checkboxCheck}>✓</Text>}
|
||||
</View>
|
||||
<View style={styles.checkboxContent}>
|
||||
<Text style={styles.checkboxLabel}>Solo productos con stock bajo</Text>
|
||||
<Text style={styles.checkboxDescription}>
|
||||
Incluir unicamente productos que necesitan reabastecimiento
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoTitle}>Que incluye el archivo?</Text>
|
||||
<Text style={styles.infoText}>
|
||||
• Nombre del producto{'\n'}
|
||||
• Cantidad en inventario{'\n'}
|
||||
• Categoria{'\n'}
|
||||
• Codigo de barras{'\n'}
|
||||
• Precio y costo{'\n'}
|
||||
• Fecha de ultima actualizacion
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.exportButton, !currentStore && styles.exportButtonDisabled]}
|
||||
onPress={handleExport}
|
||||
disabled={!currentStore}
|
||||
>
|
||||
<Text style={styles.exportButtonText}>Exportar Inventario</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
centerContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
sectionTitleMargin: {
|
||||
marginTop: 24,
|
||||
},
|
||||
optionCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
optionCardSelected: {
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: '#eff6ff',
|
||||
},
|
||||
optionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
radio: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: '#ccc',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
radioSelected: {
|
||||
borderColor: '#2563eb',
|
||||
},
|
||||
radioInner: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
optionDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginLeft: 36,
|
||||
},
|
||||
checkboxRow: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
checkbox: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
borderWidth: 2,
|
||||
borderColor: '#ccc',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
checkboxChecked: {
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
checkboxCheck: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
checkboxContent: {
|
||||
flex: 1,
|
||||
},
|
||||
checkboxLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
checkboxDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: '#eff6ff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 24,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1e40af',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: '#1e40af',
|
||||
lineHeight: 22,
|
||||
},
|
||||
footer: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
},
|
||||
exportButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
exportButtonDisabled: {
|
||||
backgroundColor: '#93c5fd',
|
||||
},
|
||||
exportButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
processingTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginTop: 24,
|
||||
},
|
||||
processingStatus: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 8,
|
||||
},
|
||||
successIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#dcfce7',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
successIconText: {
|
||||
fontSize: 40,
|
||||
color: '#22c55e',
|
||||
},
|
||||
successTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
successFilename: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 32,
|
||||
},
|
||||
actionButtons: {
|
||||
width: '100%',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: '#1a1a1a',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
linkButton: {
|
||||
paddingVertical: 12,
|
||||
},
|
||||
linkButtonText: {
|
||||
color: '#2563eb',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
errorIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#fef2f2',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
errorIconText: {
|
||||
fontSize: 40,
|
||||
color: '#ef4444',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorMessage: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
});
|
||||
@ -1,197 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function PrivacyScreen() {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Politica de Privacidad',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.lastUpdated}>Ultima actualizacion: Enero 2025</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>1. Informacion que Recopilamos</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Recopilamos la siguiente informacion:{'\n\n'}
|
||||
<Text style={styles.bold}>Informacion de cuenta:</Text>{'\n'}
|
||||
- Numero de telefono{'\n'}
|
||||
- Nombre{'\n'}
|
||||
- Correo electronico (opcional){'\n\n'}
|
||||
<Text style={styles.bold}>Informacion de negocio:</Text>{'\n'}
|
||||
- Nombre de la tienda{'\n'}
|
||||
- Ubicacion{'\n'}
|
||||
- Giro del negocio{'\n\n'}
|
||||
<Text style={styles.bold}>Videos e imagenes:</Text>{'\n'}
|
||||
- Videos grabados para escaneo de inventario{'\n'}
|
||||
- Imagenes extraidas para procesamiento de IA
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>2. Como Usamos tu Informacion</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Usamos tu informacion para:{'\n\n'}
|
||||
- Procesar y detectar productos en tus videos{'\n'}
|
||||
- Gestionar tu cuenta y tiendas{'\n'}
|
||||
- Procesar pagos de creditos{'\n'}
|
||||
- Enviarte notificaciones sobre tu cuenta{'\n'}
|
||||
- Mejorar nuestros servicios y algoritmos de IA{'\n'}
|
||||
- Responder a tus solicitudes de soporte
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>3. Almacenamiento de Videos</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Los videos que subes son procesados para detectar productos y luego se eliminan
|
||||
automaticamente despues de 30 dias. Las imagenes extraidas para el procesamiento
|
||||
de IA se eliminan inmediatamente despues del analisis.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>4. Compartir Informacion</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
No vendemos tu informacion personal. Compartimos datos solo con:{'\n\n'}
|
||||
- <Text style={styles.bold}>Stripe:</Text> Para procesar pagos de forma segura{'\n'}
|
||||
- <Text style={styles.bold}>Proveedores de IA:</Text> OpenAI/Anthropic para detectar productos{'\n'}
|
||||
- <Text style={styles.bold}>Firebase:</Text> Para enviar notificaciones push{'\n'}
|
||||
- <Text style={styles.bold}>AWS/MinIO:</Text> Para almacenar videos temporalmente
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>5. Seguridad</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Implementamos medidas de seguridad para proteger tu informacion:{'\n\n'}
|
||||
- Conexiones cifradas (HTTPS/TLS){'\n'}
|
||||
- Almacenamiento seguro de contrasenas (hash + salt){'\n'}
|
||||
- Tokens de autenticacion JWT{'\n'}
|
||||
- Acceso restringido a datos personales
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>6. Tus Derechos</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Tienes derecho a:{'\n\n'}
|
||||
- Acceder a tu informacion personal{'\n'}
|
||||
- Corregir datos inexactos{'\n'}
|
||||
- Solicitar la eliminacion de tu cuenta{'\n'}
|
||||
- Revocar el consentimiento para notificaciones{'\n'}
|
||||
- Exportar tus datos de inventario
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>7. Retencion de Datos</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Conservamos tu informacion mientras mantengas una cuenta activa. Si eliminas tu cuenta,
|
||||
eliminaremos tus datos personales dentro de 30 dias, excepto cuando la ley requiera
|
||||
mantener ciertos registros.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>8. Cookies y Tecnologias</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Usamos tecnologias como almacenamiento local para mantener tu sesion activa y
|
||||
recordar tus preferencias. No usamos cookies de terceros para publicidad.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>9. Menores de Edad</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
MiInventario no esta dirigido a menores de 18 anos. No recopilamos intencionalmente
|
||||
informacion de menores de edad.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>10. Cambios a esta Politica</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Podemos actualizar esta politica periodicamente. Te notificaremos sobre cambios
|
||||
significativos a traves de la aplicacion o por correo electronico.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>11. Contacto</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Para preguntas sobre privacidad, contactanos en:{'\n'}
|
||||
Email: privacidad@miinventario.com{'\n'}
|
||||
Telefono: +52 55 1234 5678
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
Al usar MiInventario, aceptas esta Politica de Privacidad y el procesamiento de
|
||||
tu informacion como se describe aqui.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
lastUpdated: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 20,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 14,
|
||||
color: '#444',
|
||||
lineHeight: 22,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
footer: {
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
@ -1,164 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function TermsScreen() {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Terminos y Condiciones',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.lastUpdated}>Ultima actualizacion: Enero 2025</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>1. Aceptacion de Terminos</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Al descargar, instalar o usar la aplicacion MiInventario, aceptas estos Terminos y Condiciones.
|
||||
Si no estas de acuerdo con alguno de estos terminos, no debes usar la aplicacion.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>2. Descripcion del Servicio</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
MiInventario es una aplicacion movil que utiliza inteligencia artificial para ayudarte a
|
||||
gestionar el inventario de tu negocio mediante el escaneo de video de tus productos.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>3. Registro y Cuenta</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Para usar MiInventario debes crear una cuenta proporcionando informacion veraz y actualizada.
|
||||
Eres responsable de mantener la confidencialidad de tu cuenta y contrasena.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>4. Sistema de Creditos</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
MiInventario utiliza un sistema de creditos para el procesamiento de videos:{'\n\n'}
|
||||
- Cada escaneo de video consume 1 credito{'\n'}
|
||||
- Los creditos comprados no son reembolsables{'\n'}
|
||||
- Los creditos no tienen fecha de expiracion{'\n'}
|
||||
- Los creditos no son transferibles entre cuentas
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>5. Pagos</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Los pagos se procesan a traves de Stripe de forma segura. Aceptamos tarjetas de credito/debito
|
||||
y pagos en efectivo a traves de OXXO. Los precios estan en pesos mexicanos (MXN) e incluyen IVA.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>6. Uso Aceptable</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Te comprometes a:{'\n\n'}
|
||||
- Usar la aplicacion solo para fines legales{'\n'}
|
||||
- No intentar acceder a cuentas de otros usuarios{'\n'}
|
||||
- No interferir con el funcionamiento del servicio{'\n'}
|
||||
- No usar la aplicacion para fines fraudulentos
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>7. Propiedad Intelectual</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
MiInventario y todo su contenido, caracteristicas y funcionalidad son propiedad de
|
||||
MiInventario y estan protegidos por leyes de propiedad intelectual.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>8. Limitacion de Responsabilidad</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
MiInventario se proporciona "tal cual" sin garantias de ningun tipo. No garantizamos
|
||||
la precision del 100% en la deteccion de productos. Debes verificar la informacion generada
|
||||
por la aplicacion.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>9. Modificaciones</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Nos reservamos el derecho de modificar estos terminos en cualquier momento.
|
||||
Te notificaremos sobre cambios importantes a traves de la aplicacion o por correo electronico.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>10. Contacto</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Para preguntas sobre estos terminos, contactanos en:{'\n'}
|
||||
Email: legal@miinventario.com{'\n'}
|
||||
Telefono: +52 55 1234 5678
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
Al usar MiInventario, confirmas que has leido y aceptado estos Terminos y Condiciones.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
lastUpdated: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 20,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 14,
|
||||
color: '#444',
|
||||
lineHeight: 22,
|
||||
},
|
||||
footer: {
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
@ -1,19 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function NotificationsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#fff' },
|
||||
headerTintColor: '#1a1a1a',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{ title: 'Notificaciones' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -1,312 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { router, Stack } from 'expo-router';
|
||||
import { useNotificationsStore } from '@stores/notifications.store';
|
||||
import { Notification, NotificationType } from '@services/api/notifications.service';
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
hasMore,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
isLoading,
|
||||
} = useNotificationsStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications(true);
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await fetchNotifications(true);
|
||||
setRefreshing(false);
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore && !isLoading) {
|
||||
fetchNotifications(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationPress = async (notification: Notification) => {
|
||||
if (!notification.isRead) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
|
||||
// Navigate based on notification type
|
||||
const data = notification.data as Record<string, string> | undefined;
|
||||
switch (notification.type) {
|
||||
case 'VIDEO_PROCESSING_COMPLETE':
|
||||
if (data?.videoId && data?.storeId) {
|
||||
router.push(`/inventory?storeId=${data.storeId}`);
|
||||
}
|
||||
break;
|
||||
case 'PAYMENT_COMPLETE':
|
||||
router.push('/credits/history');
|
||||
break;
|
||||
case 'REFERRAL_BONUS':
|
||||
router.push('/referrals');
|
||||
break;
|
||||
case 'LOW_CREDITS':
|
||||
router.push('/credits/buy');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type: NotificationType) => {
|
||||
switch (type) {
|
||||
case 'VIDEO_PROCESSING_COMPLETE':
|
||||
return '✅';
|
||||
case 'VIDEO_PROCESSING_FAILED':
|
||||
return '❌';
|
||||
case 'LOW_CREDITS':
|
||||
return '⚠️';
|
||||
case 'PAYMENT_COMPLETE':
|
||||
return '💰';
|
||||
case 'PAYMENT_FAILED':
|
||||
return '💳';
|
||||
case 'REFERRAL_BONUS':
|
||||
return '🎁';
|
||||
default:
|
||||
return '📢';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 60) {
|
||||
return diffMins <= 1 ? 'Hace un momento' : `Hace ${diffMins} min`;
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return diffHours === 1 ? 'Hace 1 hora' : `Hace ${diffHours} horas`;
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return diffDays === 1 ? 'Ayer' : `Hace ${diffDays} dias`;
|
||||
}
|
||||
return date.toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const renderNotification = ({ item }: { item: Notification }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.notificationCard,
|
||||
!item.isRead && styles.notificationCardUnread,
|
||||
]}
|
||||
onPress={() => handleNotificationPress(item)}
|
||||
>
|
||||
<View style={styles.notificationIcon}>
|
||||
<Text style={styles.notificationIconText}>
|
||||
{getNotificationIcon(item.type)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.notificationContent}>
|
||||
<View style={styles.notificationHeader}>
|
||||
<Text
|
||||
style={[
|
||||
styles.notificationTitle,
|
||||
!item.isRead && styles.notificationTitleUnread,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
{!item.isRead && <View style={styles.unreadDot} />}
|
||||
</View>
|
||||
<Text style={styles.notificationBody} numberOfLines={2}>
|
||||
{item.body}
|
||||
</Text>
|
||||
<Text style={styles.notificationTime}>{formatDate(item.createdAt)}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const EmptyState = () => (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>🔔</Text>
|
||||
<Text style={styles.emptyTitle}>Sin notificaciones</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
Aqui veras las notificaciones sobre tus escaneos, pagos y referidos
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerRight: () =>
|
||||
unreadCount > 0 ? (
|
||||
<TouchableOpacity
|
||||
style={styles.markAllButton}
|
||||
onPress={markAllAsRead}
|
||||
>
|
||||
<Text style={styles.markAllButtonText}>Marcar leidas</Text>
|
||||
</TouchableOpacity>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text style={styles.loadingText}>Cargando notificaciones...</Text>
|
||||
</View>
|
||||
) : notifications.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<FlatList
|
||||
data={notifications}
|
||||
renderItem={renderNotification}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
isLoading ? (
|
||||
<ActivityIndicator style={styles.footerLoader} color="#2563eb" />
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
markAllButton: {
|
||||
marginRight: 8,
|
||||
},
|
||||
markAllButtonText: {
|
||||
color: '#2563eb',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
notificationCard: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
notificationCardUnread: {
|
||||
backgroundColor: '#f0f9ff',
|
||||
},
|
||||
notificationIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#f5f5f5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
notificationIconText: {
|
||||
fontSize: 20,
|
||||
},
|
||||
notificationContent: {
|
||||
flex: 1,
|
||||
},
|
||||
notificationHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
notificationTitle: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
notificationTitleUnread: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
unreadDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#2563eb',
|
||||
marginLeft: 8,
|
||||
},
|
||||
notificationBody: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
marginBottom: 4,
|
||||
},
|
||||
notificationTime: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
separator: {
|
||||
height: 8,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
footerLoader: {
|
||||
paddingVertical: 16,
|
||||
},
|
||||
});
|
||||
@ -1,288 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Stack, router } from 'expo-router';
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
type: 'card' | 'oxxo' | '7eleven';
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
const paymentMethods: PaymentMethod[] = [
|
||||
{
|
||||
id: 'card',
|
||||
type: 'card',
|
||||
name: 'Tarjeta de Credito/Debito',
|
||||
description: 'Visa, Mastercard, American Express',
|
||||
icon: '💳',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'oxxo',
|
||||
type: 'oxxo',
|
||||
name: 'OXXO',
|
||||
description: 'Paga en efectivo en cualquier OXXO',
|
||||
icon: '🏪',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: '7eleven',
|
||||
type: '7eleven',
|
||||
name: '7-Eleven',
|
||||
description: 'Proximamente disponible',
|
||||
icon: '🏬',
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function PaymentMethodsScreen() {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Metodos de Pago',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
<ScrollView style={styles.scroll}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Metodos Disponibles</Text>
|
||||
<Text style={styles.sectionDescription}>
|
||||
Selecciona tu metodo de pago preferido al comprar creditos
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.methodsList}>
|
||||
{paymentMethods.map((method) => (
|
||||
<View
|
||||
key={method.id}
|
||||
style={[
|
||||
styles.methodCard,
|
||||
!method.available && styles.methodCardDisabled,
|
||||
]}
|
||||
>
|
||||
<View style={styles.methodIcon}>
|
||||
<Text style={styles.methodIconText}>{method.icon}</Text>
|
||||
</View>
|
||||
<View style={styles.methodInfo}>
|
||||
<Text
|
||||
style={[
|
||||
styles.methodName,
|
||||
!method.available && styles.methodNameDisabled,
|
||||
]}
|
||||
>
|
||||
{method.name}
|
||||
</Text>
|
||||
<Text style={styles.methodDescription}>{method.description}</Text>
|
||||
</View>
|
||||
{method.available ? (
|
||||
<View style={styles.checkmark}>
|
||||
<Text style={styles.checkmarkText}>✓</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.comingSoon}>
|
||||
<Text style={styles.comingSoonText}>Pronto</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.infoTitle}>Sobre los pagos</Text>
|
||||
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoIcon}>🔒</Text>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Pagos Seguros</Text>
|
||||
<Text style={styles.infoText}>
|
||||
Todos los pagos son procesados de forma segura a traves de Stripe
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoIcon}>⚡</Text>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Creditos Instantaneos</Text>
|
||||
<Text style={styles.infoText}>
|
||||
Los creditos se acreditan inmediatamente al pagar con tarjeta
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoIcon}>🏪</Text>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoLabel}>Pago en Efectivo</Text>
|
||||
<Text style={styles.infoText}>
|
||||
Recibe un voucher para pagar en OXXO. Los creditos se acreditan en 24-48 horas
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.buyButton}
|
||||
onPress={() => router.push('/credits/buy')}
|
||||
>
|
||||
<Text style={styles.buyButtonText}>Comprar Creditos</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
sectionDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
methodsList: {
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
methodCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
methodCardDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
methodIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#f5f5f5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
methodIconText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
methodInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
methodName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 2,
|
||||
},
|
||||
methodNameDisabled: {
|
||||
color: '#999',
|
||||
},
|
||||
methodDescription: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
},
|
||||
checkmark: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#22c55e',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkmarkText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
comingSoon: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
comingSoonText: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
fontWeight: '500',
|
||||
},
|
||||
infoSection: {
|
||||
margin: 16,
|
||||
marginTop: 8,
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 16,
|
||||
},
|
||||
infoItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
},
|
||||
infoIcon: {
|
||||
fontSize: 20,
|
||||
marginRight: 12,
|
||||
},
|
||||
infoContent: {
|
||||
flex: 1,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 2,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
lineHeight: 18,
|
||||
},
|
||||
buyButton: {
|
||||
margin: 16,
|
||||
marginTop: 8,
|
||||
backgroundColor: '#2563eb',
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buyButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
@ -1,316 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { router, Stack } from 'expo-router';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuthStore } from '@stores/auth.store';
|
||||
import { usersService, UpdateProfileRequest } from '@services/api/users.service';
|
||||
|
||||
export default function EditProfileScreen() {
|
||||
const { user, setUser } = useAuthStore();
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
const [email, setEmail] = useState(user?.email || '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const profile = await usersService.getProfile();
|
||||
setName(profile.name);
|
||||
setEmail(profile.email || '');
|
||||
} catch (err) {
|
||||
console.error('Error loading profile:', err);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateEmail = (email: string) => {
|
||||
if (!email) return true; // Email is optional
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('El nombre es requerido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
setError('Email invalido');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const updateData: UpdateProfileRequest = {
|
||||
name: name.trim(),
|
||||
};
|
||||
|
||||
if (email.trim()) {
|
||||
updateData.email = email.trim();
|
||||
}
|
||||
|
||||
const updatedProfile = await usersService.updateProfile(updateData);
|
||||
|
||||
setUser({
|
||||
id: updatedProfile.id,
|
||||
phone: updatedProfile.phone,
|
||||
name: updatedProfile.name,
|
||||
email: updatedProfile.email,
|
||||
});
|
||||
|
||||
Alert.alert('Exito', 'Perfil actualizado correctamente', [
|
||||
{ text: 'OK', onPress: () => router.back() },
|
||||
]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al actualizar perfil');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Editar Perfil',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Editar Perfil',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>
|
||||
{name?.charAt(0).toUpperCase() || 'U'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Nombre</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Tu nombre"
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Telefono</Text>
|
||||
<View style={styles.disabledInput}>
|
||||
<Text style={styles.disabledInputText}>{user?.phone}</Text>
|
||||
</View>
|
||||
<Text style={styles.helperText}>
|
||||
El numero de telefono no puede ser modificado
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Email (opcional)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="tu@email.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={() => router.back()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancelar</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, isLoading && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>Guardar</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
avatarContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 24,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
avatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: '#2563eb',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 40,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
form: {
|
||||
padding: 16,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
},
|
||||
disabledInput: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
},
|
||||
disabledInputText: {
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 4,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: '#fef2f2',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 14,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
gap: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
saveButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
@ -1,19 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ReferralsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#fff' },
|
||||
headerTintColor: '#1a1a1a',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{ title: 'Mis Referidos' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -1,460 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
Share,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { useReferralsStore } from '@stores/referrals.store';
|
||||
import { Referral } from '@services/api/referrals.service';
|
||||
|
||||
export default function ReferralsScreen() {
|
||||
const {
|
||||
stats,
|
||||
referrals,
|
||||
hasMore,
|
||||
fetchStats,
|
||||
fetchReferrals,
|
||||
isLoading,
|
||||
} = useReferralsStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
fetchReferrals(true);
|
||||
}, [fetchStats, fetchReferrals]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([fetchStats(), fetchReferrals(true)]);
|
||||
setRefreshing(false);
|
||||
}, [fetchStats, fetchReferrals]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore && !isLoading) {
|
||||
fetchReferrals(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCode = async () => {
|
||||
if (stats?.referralCode) {
|
||||
await Clipboard.setStringAsync(stats.referralCode);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const shareCode = async () => {
|
||||
if (stats?.referralCode) {
|
||||
try {
|
||||
await Share.share({
|
||||
message: `Te invito a usar MiInventario, la app que te ayuda a controlar tu inventario con solo grabar un video. Usa mi codigo ${stats.referralCode} y ambos ganaremos creditos gratis!`,
|
||||
});
|
||||
} catch {
|
||||
// User cancelled
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'REWARDED':
|
||||
return '#22c55e';
|
||||
case 'QUALIFIED':
|
||||
return '#3b82f6';
|
||||
case 'REGISTERED':
|
||||
return '#f59e0b';
|
||||
default:
|
||||
return '#9ca3af';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'REWARDED':
|
||||
return 'Completado';
|
||||
case 'QUALIFIED':
|
||||
return 'Calificado';
|
||||
case 'REGISTERED':
|
||||
return 'Registrado';
|
||||
default:
|
||||
return 'Pendiente';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const renderReferral = ({ item }: { item: Referral }) => (
|
||||
<View style={styles.referralCard}>
|
||||
<View style={styles.referralAvatar}>
|
||||
<Text style={styles.referralAvatarText}>
|
||||
{item.referred?.name?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.referralInfo}>
|
||||
<Text style={styles.referralName}>
|
||||
{item.referred?.name || 'Usuario'}
|
||||
</Text>
|
||||
<Text style={styles.referralDate}>
|
||||
Registrado: {formatDate(item.registeredAt || item.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.referralStatus}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: getStatusColor(item.status) + '20' },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
{ color: getStatusColor(item.status) },
|
||||
]}
|
||||
>
|
||||
{getStatusLabel(item.status)}
|
||||
</Text>
|
||||
</View>
|
||||
{item.status === 'REWARDED' && (
|
||||
<Text style={styles.bonusText}>+{item.referrerBonusCredits}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const ListHeader = () => (
|
||||
<View style={styles.header}>
|
||||
{/* Share Card */}
|
||||
<View style={styles.shareCard}>
|
||||
<Text style={styles.shareTitle}>Tu codigo de referido</Text>
|
||||
<View style={styles.codeContainer}>
|
||||
<Text style={styles.codeText}>{stats?.referralCode || '---'}</Text>
|
||||
</View>
|
||||
<View style={styles.shareActions}>
|
||||
<TouchableOpacity style={styles.shareButton} onPress={copyCode}>
|
||||
<Text style={styles.shareButtonIcon}>{copied ? '✓' : '📋'}</Text>
|
||||
<Text style={styles.shareButtonText}>
|
||||
{copied ? 'Copiado!' : 'Copiar'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.shareButton, styles.shareButtonPrimary]}
|
||||
onPress={shareCode}
|
||||
>
|
||||
<Text style={styles.shareButtonIcon}>📤</Text>
|
||||
<Text style={[styles.shareButtonText, styles.shareButtonTextPrimary]}>
|
||||
Compartir
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats?.totalReferrals ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>Invitados</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{stats?.completedReferrals ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>Completados</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={[styles.statValue, styles.statValueHighlight]}>
|
||||
{stats?.totalCreditsEarned ?? 0}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Creditos</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* How it works */}
|
||||
<View style={styles.howItWorks}>
|
||||
<Text style={styles.howItWorksTitle}>Como funciona</Text>
|
||||
<View style={styles.step}>
|
||||
<View style={styles.stepNumber}>
|
||||
<Text style={styles.stepNumberText}>1</Text>
|
||||
</View>
|
||||
<Text style={styles.stepText}>Comparte tu codigo con amigos</Text>
|
||||
</View>
|
||||
<View style={styles.step}>
|
||||
<View style={styles.stepNumber}>
|
||||
<Text style={styles.stepNumberText}>2</Text>
|
||||
</View>
|
||||
<Text style={styles.stepText}>Tu amigo se registra con el codigo</Text>
|
||||
</View>
|
||||
<View style={styles.step}>
|
||||
<View style={styles.stepNumber}>
|
||||
<Text style={styles.stepNumberText}>3</Text>
|
||||
</View>
|
||||
<Text style={styles.stepText}>
|
||||
Ambos reciben 5 creditos cuando tu amigo hace su primer escaneo
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{referrals.length > 0 && (
|
||||
<Text style={styles.listTitle}>Tus referidos</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const EmptyReferrals = () => (
|
||||
<View style={styles.emptyReferrals}>
|
||||
<Text style={styles.emptyIcon}>👥</Text>
|
||||
<Text style={styles.emptyTitle}>Sin referidos aun</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
Comparte tu codigo y empieza a ganar creditos
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<FlatList
|
||||
data={referrals}
|
||||
renderItem={renderReferral}
|
||||
keyExtractor={(item) => item.id}
|
||||
ListHeaderComponent={ListHeader}
|
||||
ListEmptyComponent={
|
||||
isLoading ? null : <EmptyReferrals />
|
||||
}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
isLoading && referrals.length > 0 ? (
|
||||
<ActivityIndicator style={styles.footerLoader} color="#2563eb" />
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
shareCard: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
shareTitle: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
codeContainer: {
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
codeText: {
|
||||
color: '#fff',
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
letterSpacing: 3,
|
||||
},
|
||||
shareActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
shareButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
gap: 8,
|
||||
},
|
||||
shareButtonPrimary: {
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
shareButtonIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
shareButtonText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
shareButtonTextPrimary: {
|
||||
color: '#2563eb',
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
statValueHighlight: {
|
||||
color: '#22c55e',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
howItWorks: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
howItWorksTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 12,
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
stepNumber: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#2563eb',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
stepNumberText: {
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
stepText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
listTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 12,
|
||||
},
|
||||
referralCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
referralAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#e0e7ff',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
referralAvatarText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#4f46e5',
|
||||
},
|
||||
referralInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
referralName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
referralDate: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
referralStatus: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
},
|
||||
statusBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
bonusText: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: '#22c55e',
|
||||
marginTop: 4,
|
||||
},
|
||||
separator: {
|
||||
height: 8,
|
||||
},
|
||||
emptyReferrals: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
footerLoader: {
|
||||
paddingVertical: 16,
|
||||
},
|
||||
});
|
||||
@ -1,31 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ReportsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#fff' },
|
||||
headerTintColor: '#1a1a1a',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{ title: 'Reportes' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="valuation"
|
||||
options={{ title: 'Valorizacion' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="movements"
|
||||
options={{ title: 'Movimientos' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="categories"
|
||||
options={{ title: 'Categorias' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -1,479 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
import { reportsService, CategoriesReport, CategoryDetail } from '@services/api/reports.service';
|
||||
|
||||
const CATEGORY_COLORS = [
|
||||
'#3b82f6',
|
||||
'#22c55e',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#8b5cf6',
|
||||
'#06b6d4',
|
||||
'#ec4899',
|
||||
'#84cc16',
|
||||
];
|
||||
|
||||
export default function CategoriesReportScreen() {
|
||||
const { currentStore } = useStoresStore();
|
||||
const [report, setReport] = useState<CategoriesReport | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||
|
||||
const fetchReport = useCallback(async (showRefresh = false) => {
|
||||
if (!currentStore) return;
|
||||
|
||||
if (showRefresh) {
|
||||
setIsRefreshing(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await reportsService.getCategoriesReport(currentStore.id);
|
||||
setReport(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar reporte');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [currentStore]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport();
|
||||
}, [fetchReport]);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return `$${value.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const toggleCategory = (name: string) => {
|
||||
setExpandedCategory(expandedCategory === name ? null : name);
|
||||
};
|
||||
|
||||
const renderCategoryBar = (categories: CategoryDetail[]) => {
|
||||
return (
|
||||
<View style={styles.barContainer}>
|
||||
{categories.map((cat, index) => (
|
||||
<View
|
||||
key={cat.name}
|
||||
style={[
|
||||
styles.barSegment,
|
||||
{
|
||||
flex: cat.percentOfTotal,
|
||||
backgroundColor: CATEGORY_COLORS[index % CATEGORY_COLORS.length],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCategoryCard = (category: CategoryDetail, index: number) => {
|
||||
const isExpanded = expandedCategory === category.name;
|
||||
const color = CATEGORY_COLORS[index % CATEGORY_COLORS.length];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={category.name}
|
||||
style={styles.categoryCard}
|
||||
onPress={() => toggleCategory(category.name)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.categoryHeader}>
|
||||
<View style={styles.categoryLeft}>
|
||||
<View style={[styles.categoryDot, { backgroundColor: color }]} />
|
||||
<View style={styles.categoryInfo}>
|
||||
<Text style={styles.categoryName}>{category.name || 'Sin categoria'}</Text>
|
||||
<Text style={styles.categoryCount}>{category.itemCount} productos</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.categoryRight}>
|
||||
<Text style={styles.categoryPercent}>{formatPercent(category.percentOfTotal)}</Text>
|
||||
<Text style={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isExpanded && (
|
||||
<View style={styles.categoryExpanded}>
|
||||
<View style={styles.statRow}>
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statLabel}>Valor total</Text>
|
||||
<Text style={styles.statValue}>{formatCurrency(category.totalValue)}</Text>
|
||||
</View>
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statLabel}>Precio promedio</Text>
|
||||
<Text style={styles.statValue}>{formatCurrency(category.averagePrice)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{category.lowStockCount > 0 && (
|
||||
<View style={styles.alertRow}>
|
||||
<View style={styles.alertBadge}>
|
||||
<Text style={styles.alertBadgeText}>
|
||||
{category.lowStockCount} productos con stock bajo
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{category.topItems.length > 0 && (
|
||||
<View style={styles.topItems}>
|
||||
<Text style={styles.topItemsTitle}>Productos principales:</Text>
|
||||
{category.topItems.map((item, i) => (
|
||||
<View key={i} style={styles.topItem}>
|
||||
<Text style={styles.topItemName} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={styles.topItemQuantity}>x{item.quantity}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={() => fetchReport()}>
|
||||
<Text style={styles.retryButtonText}>Reintentar</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No hay datos disponibles</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isRefreshing} onRefresh={() => fetchReport(true)} />
|
||||
}
|
||||
>
|
||||
{/* Summary Card */}
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={styles.summaryStats}>
|
||||
<View style={styles.summaryStat}>
|
||||
<Text style={styles.summaryStatValue}>{report.summary.totalCategories}</Text>
|
||||
<Text style={styles.summaryStatLabel}>Categorias</Text>
|
||||
</View>
|
||||
<View style={styles.summaryDivider} />
|
||||
<View style={styles.summaryStat}>
|
||||
<Text style={styles.summaryStatValue}>{report.summary.totalItems}</Text>
|
||||
<Text style={styles.summaryStatLabel}>Productos</Text>
|
||||
</View>
|
||||
<View style={styles.summaryDivider} />
|
||||
<View style={styles.summaryStat}>
|
||||
<Text style={styles.summaryStatValue}>{formatCurrency(report.summary.totalValue)}</Text>
|
||||
<Text style={styles.summaryStatLabel}>Valor Total</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Distribution Bar */}
|
||||
<Text style={styles.sectionTitle}>Distribucion</Text>
|
||||
{renderCategoryBar(report.categories)}
|
||||
|
||||
{/* Legend */}
|
||||
<View style={styles.legend}>
|
||||
{report.categories.slice(0, 4).map((cat, index) => (
|
||||
<View key={cat.name} style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: CATEGORY_COLORS[index] }]} />
|
||||
<Text style={styles.legendText} numberOfLines={1}>{cat.name || 'Sin cat.'}</Text>
|
||||
</View>
|
||||
))}
|
||||
{report.categories.length > 4 && (
|
||||
<Text style={styles.legendMore}>+{report.categories.length - 4} mas</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Category Cards */}
|
||||
<Text style={[styles.sectionTitle, styles.sectionTitleMargin]}>Desglose por categoria</Text>
|
||||
{report.categories.map((category, index) => renderCategoryCard(category, index))}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: '#ef4444',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
summaryCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
summaryStats: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
summaryStat: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
summaryDivider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: '#e5e5e5',
|
||||
},
|
||||
summaryStatValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
summaryStatLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
sectionTitleMargin: {
|
||||
marginTop: 16,
|
||||
},
|
||||
barContainer: {
|
||||
flexDirection: 'row',
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#e5e5e5',
|
||||
},
|
||||
barSegment: {
|
||||
height: '100%',
|
||||
},
|
||||
legend: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 12,
|
||||
gap: 12,
|
||||
},
|
||||
legendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
legendDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginRight: 6,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
maxWidth: 80,
|
||||
},
|
||||
legendMore: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
categoryCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
categoryHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
categoryLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
categoryDot: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
marginRight: 12,
|
||||
},
|
||||
categoryInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
categoryName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
categoryCount: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
categoryRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
categoryPercent: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
expandIcon: {
|
||||
fontSize: 10,
|
||||
color: '#666',
|
||||
},
|
||||
categoryExpanded: {
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f5f5f5',
|
||||
},
|
||||
statRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 12,
|
||||
},
|
||||
stat: {
|
||||
flex: 1,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
alertRow: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
alertBadge: {
|
||||
backgroundColor: '#fef2f2',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
alertBadgeText: {
|
||||
fontSize: 12,
|
||||
color: '#ef4444',
|
||||
fontWeight: '500',
|
||||
},
|
||||
topItems: {
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
},
|
||||
topItemsTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
topItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 4,
|
||||
},
|
||||
topItemName: {
|
||||
fontSize: 14,
|
||||
color: '#1a1a1a',
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
topItemQuantity: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@ -1,150 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
interface ReportCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const ReportCard = ({ title, description, icon, route, color }: ReportCardProps) => (
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
onPress={() => router.push(route as any)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.iconContainer, { backgroundColor: color }]}>
|
||||
<Text style={styles.icon}>{icon}</Text>
|
||||
</View>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={styles.cardTitle}>{title}</Text>
|
||||
<Text style={styles.cardDescription}>{description}</Text>
|
||||
</View>
|
||||
<Text style={styles.chevron}>›</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
export default function ReportsIndexScreen() {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.sectionTitle}>Reportes disponibles</Text>
|
||||
|
||||
<ReportCard
|
||||
title="Valorizacion del Inventario"
|
||||
description="Valor total, costos y margenes potenciales de tu inventario"
|
||||
icon="$"
|
||||
route="/reports/valuation"
|
||||
color="#dcfce7"
|
||||
/>
|
||||
|
||||
<ReportCard
|
||||
title="Historial de Movimientos"
|
||||
description="Entradas, salidas y ajustes de stock"
|
||||
icon="↕"
|
||||
route="/reports/movements"
|
||||
color="#dbeafe"
|
||||
/>
|
||||
|
||||
<ReportCard
|
||||
title="Analisis por Categorias"
|
||||
description="Distribucion de productos y valor por categoria"
|
||||
icon="◫"
|
||||
route="/reports/categories"
|
||||
color="#fef3c7"
|
||||
/>
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoTitle}>Exportar reportes</Text>
|
||||
<Text style={styles.infoText}>
|
||||
Todos los reportes pueden exportarse en formato CSV o Excel desde la
|
||||
pantalla de cada reporte.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
cardDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
chevron: {
|
||||
fontSize: 24,
|
||||
color: '#ccc',
|
||||
marginLeft: 8,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: '#eff6ff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 12,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1e40af',
|
||||
marginBottom: 4,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: '#1e40af',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@ -1,371 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
import { reportsService, MovementsReport, MovementRecord } from '@services/api/reports.service';
|
||||
|
||||
const MOVEMENT_TYPES: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||
DETECTION: { label: 'Deteccion', color: '#2563eb', bgColor: '#dbeafe' },
|
||||
MANUAL_ADJUST: { label: 'Ajuste', color: '#7c3aed', bgColor: '#ede9fe' },
|
||||
SALE: { label: 'Venta', color: '#ef4444', bgColor: '#fef2f2' },
|
||||
PURCHASE: { label: 'Compra', color: '#22c55e', bgColor: '#dcfce7' },
|
||||
};
|
||||
|
||||
export default function MovementsReportScreen() {
|
||||
const { currentStore } = useStoresStore();
|
||||
const [report, setReport] = useState<MovementsReport | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const fetchReport = useCallback(async (pageNum = 1, refresh = false) => {
|
||||
if (!currentStore) return;
|
||||
|
||||
if (pageNum === 1) {
|
||||
if (refresh) {
|
||||
setIsRefreshing(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
} else {
|
||||
setIsLoadingMore(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await reportsService.getMovementsReport(currentStore.id, {
|
||||
page: pageNum,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
if (pageNum === 1) {
|
||||
setReport(data);
|
||||
} else if (report) {
|
||||
setReport({
|
||||
...data,
|
||||
movements: [...report.movements, ...data.movements],
|
||||
});
|
||||
}
|
||||
setPage(pageNum);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar reporte');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [currentStore, report]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport();
|
||||
}, [currentStore]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!report?.hasMore || isLoadingMore) return;
|
||||
fetchReport(page + 1);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const renderMovementItem = ({ item }: { item: MovementRecord }) => {
|
||||
const typeConfig = MOVEMENT_TYPES[item.type] || MOVEMENT_TYPES.MANUAL_ADJUST;
|
||||
const isPositive = item.change > 0;
|
||||
|
||||
return (
|
||||
<View style={styles.movementCard}>
|
||||
<View style={styles.movementHeader}>
|
||||
<View style={[styles.typeBadge, { backgroundColor: typeConfig.bgColor }]}>
|
||||
<Text style={[styles.typeBadgeText, { color: typeConfig.color }]}>
|
||||
{typeConfig.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.movementDate}>{formatDate(item.date)}</Text>
|
||||
</View>
|
||||
<Text style={styles.movementItem} numberOfLines={1}>{item.itemName}</Text>
|
||||
<View style={styles.movementDetails}>
|
||||
<View style={styles.quantityChange}>
|
||||
<Text style={styles.quantityLabel}>
|
||||
{item.quantityBefore} → {item.quantityAfter}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.changeValue,
|
||||
isPositive ? styles.changePositive : styles.changeNegative,
|
||||
]}>
|
||||
{isPositive ? '+' : ''}{item.change}
|
||||
</Text>
|
||||
</View>
|
||||
{item.reason && (
|
||||
<Text style={styles.reasonText}>{item.reason}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
if (!report) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.headerSection}>
|
||||
{/* Summary Card */}
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={styles.summaryValue}>{report.summary.totalMovements}</Text>
|
||||
<Text style={styles.summaryLabel}>Movimientos</Text>
|
||||
</View>
|
||||
<View style={styles.summaryDivider} />
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[
|
||||
styles.summaryValue,
|
||||
report.summary.netChange >= 0 ? styles.changePositive : styles.changeNegative,
|
||||
]}>
|
||||
{report.summary.netChange >= 0 ? '+' : ''}{report.summary.netChange}
|
||||
</Text>
|
||||
<Text style={styles.summaryLabel}>Cambio neto</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, styles.changePositive]}>
|
||||
+{report.summary.itemsIncreased}
|
||||
</Text>
|
||||
<Text style={styles.summaryLabel}>Aumentos</Text>
|
||||
</View>
|
||||
<View style={styles.summaryDivider} />
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={[styles.summaryValue, styles.changeNegative]}>
|
||||
-{report.summary.itemsDecreased}
|
||||
</Text>
|
||||
<Text style={styles.summaryLabel}>Disminuciones</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Historial de movimientos</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!isLoadingMore) return null;
|
||||
return (
|
||||
<View style={styles.loadingMore}>
|
||||
<ActivityIndicator size="small" color="#2563eb" />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={() => fetchReport()}>
|
||||
<Text style={styles.retryButtonText}>Reintentar</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<FlatList
|
||||
data={report?.movements || []}
|
||||
renderItem={renderMovementItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
ListHeaderComponent={renderHeader}
|
||||
ListFooterComponent={renderFooter}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isRefreshing} onRefresh={() => fetchReport(1, true)} />
|
||||
}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No hay movimientos registrados</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: '#ef4444',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyContainer: {
|
||||
paddingVertical: 48,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
headerSection: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
summaryCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
summaryItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
summaryDivider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: '#e5e5e5',
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
movementCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
movementHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
typeBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
typeBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
movementDate: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
movementItem: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
movementDetails: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
quantityChange: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
quantityLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
changeValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
changePositive: {
|
||||
color: '#22c55e',
|
||||
},
|
||||
changeNegative: {
|
||||
color: '#ef4444',
|
||||
},
|
||||
reasonText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
fontStyle: 'italic',
|
||||
marginTop: 8,
|
||||
},
|
||||
loadingMore: {
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
@ -1,381 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { router } from 'expo-router';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
import { reportsService, ValuationReport } from '@services/api/reports.service';
|
||||
|
||||
export default function ValuationReportScreen() {
|
||||
const { currentStore } = useStoresStore();
|
||||
const [report, setReport] = useState<ValuationReport | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchReport = useCallback(async (showRefresh = false) => {
|
||||
if (!currentStore) return;
|
||||
|
||||
if (showRefresh) {
|
||||
setIsRefreshing(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await reportsService.getValuationReport(currentStore.id);
|
||||
setReport(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar reporte');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [currentStore]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport();
|
||||
}, [fetchReport]);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return `$${value.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
router.push('/inventory/export' as any);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={() => fetchReport()}>
|
||||
<Text style={styles.retryButtonText}>Reintentar</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No hay datos disponibles</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isRefreshing} onRefresh={() => fetchReport(true)} />
|
||||
}
|
||||
>
|
||||
{/* Summary Card */}
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.summaryTitle}>Valor Total del Inventario</Text>
|
||||
<Text style={styles.summaryValue}>{formatCurrency(report.summary.totalPrice)}</Text>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={styles.summaryItemLabel}>Costo</Text>
|
||||
<Text style={styles.summaryItemValue}>{formatCurrency(report.summary.totalCost)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryDivider} />
|
||||
<View style={styles.summaryItem}>
|
||||
<Text style={styles.summaryItemLabel}>Margen</Text>
|
||||
<Text style={[styles.summaryItemValue, styles.marginValue]}>
|
||||
{formatPercent(report.summary.potentialMarginPercent)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.totalItems}>{report.summary.totalItems} productos</Text>
|
||||
</View>
|
||||
|
||||
{/* By Category */}
|
||||
<Text style={styles.sectionTitle}>Por Categoria</Text>
|
||||
{report.byCategory.map((cat, index) => (
|
||||
<View key={index} style={styles.categoryCard}>
|
||||
<View style={styles.categoryHeader}>
|
||||
<Text style={styles.categoryName}>{cat.category || 'Sin categoria'}</Text>
|
||||
<Text style={styles.categoryCount}>{cat.itemCount} productos</Text>
|
||||
</View>
|
||||
<View style={styles.categoryStats}>
|
||||
<View style={styles.categoryStat}>
|
||||
<Text style={styles.categoryStatLabel}>Valor</Text>
|
||||
<Text style={styles.categoryStatValue}>{formatCurrency(cat.totalPrice)}</Text>
|
||||
</View>
|
||||
<View style={styles.categoryStat}>
|
||||
<Text style={styles.categoryStatLabel}>Costo</Text>
|
||||
<Text style={styles.categoryStatValue}>{formatCurrency(cat.totalCost)}</Text>
|
||||
</View>
|
||||
<View style={styles.categoryStat}>
|
||||
<Text style={styles.categoryStatLabel}>Margen</Text>
|
||||
<Text style={[styles.categoryStatValue, styles.marginValue]}>
|
||||
{formatCurrency(cat.margin)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Top Items */}
|
||||
<Text style={styles.sectionTitle}>Top Productos por Valor</Text>
|
||||
{report.items.slice(0, 10).map((item, index) => (
|
||||
<View key={item.id} style={styles.itemRow}>
|
||||
<View style={styles.itemRank}>
|
||||
<Text style={styles.itemRankText}>{index + 1}</Text>
|
||||
</View>
|
||||
<View style={styles.itemInfo}>
|
||||
<Text style={styles.itemName} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={styles.itemCategory}>{item.category || 'Sin categoria'}</Text>
|
||||
</View>
|
||||
<View style={styles.itemValue}>
|
||||
<Text style={styles.itemValueText}>{formatCurrency(item.totalPrice)}</Text>
|
||||
<Text style={styles.itemQuantity}>x{item.quantity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity style={styles.exportButton} onPress={handleExport}>
|
||||
<Text style={styles.exportButtonText}>Exportar Reporte</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: '#ef4444',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
summaryCard: {
|
||||
backgroundColor: '#1e40af',
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
summaryTitle: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
marginBottom: 8,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 36,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 24,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
summaryItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
summaryDivider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||
},
|
||||
summaryItemLabel: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
marginBottom: 4,
|
||||
},
|
||||
summaryItemValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
marginValue: {
|
||||
color: '#4ade80',
|
||||
},
|
||||
totalItems: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
categoryCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
categoryHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
categoryName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
categoryCount: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
categoryStats: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
categoryStat: {
|
||||
flex: 1,
|
||||
},
|
||||
categoryStatLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
categoryStatValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
itemRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
itemRank: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#f5f5f5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
itemRankText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
itemInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
itemName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
itemCategory: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
itemValue: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
itemValueText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
itemQuantity: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
footer: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
},
|
||||
exportButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
exportButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,264 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
|
||||
export default function EditStoreScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { stores, updateStore, deleteStore, isLoading, error } = useStoresStore();
|
||||
const [name, setName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [giro, setGiro] = useState('');
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const store = stores.find((s) => s.id === id);
|
||||
if (store) {
|
||||
setName(store.name);
|
||||
setAddress(store.address || '');
|
||||
setCity(store.city || '');
|
||||
setGiro(store.giro || '');
|
||||
}
|
||||
setIsLoadingData(false);
|
||||
}, [id, stores]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Error', 'El nombre de la tienda es requerido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) return;
|
||||
|
||||
const store = await updateStore(id, {
|
||||
name: name.trim(),
|
||||
address: address.trim() || undefined,
|
||||
city: city.trim() || undefined,
|
||||
giro: giro.trim() || undefined,
|
||||
});
|
||||
|
||||
if (store) {
|
||||
Alert.alert('Listo', 'La tienda ha sido actualizada', [
|
||||
{ text: 'OK', onPress: () => router.back() },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Eliminar Tienda',
|
||||
'Estas seguro de eliminar esta tienda? Esta accion no se puede deshacer y perderas todo el inventario asociado.',
|
||||
[
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Eliminar',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
if (!id) return;
|
||||
const success = await deleteStore(id);
|
||||
if (success) {
|
||||
Alert.alert('Listo', 'La tienda ha sido eliminada', [
|
||||
{ text: 'OK', onPress: () => router.back() },
|
||||
]);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoid}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>Nombre de la tienda *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Ej: Mi Tiendita Centro"
|
||||
placeholderTextColor="#999"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>Direccion</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Ej: Av. Juarez 123, Col. Centro"
|
||||
placeholderTextColor="#999"
|
||||
value={address}
|
||||
onChangeText={setAddress}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>Ciudad</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Ej: Ciudad de Mexico"
|
||||
placeholderTextColor="#999"
|
||||
value={city}
|
||||
onChangeText={setCity}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>Giro</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Ej: Abarrotes, Miscelanea..."
|
||||
placeholderTextColor="#999"
|
||||
value={giro}
|
||||
onChangeText={setGiro}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorCard}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<Text style={styles.deleteButtonText}>Eliminar Tienda</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.saveButton,
|
||||
(!name.trim() || isLoading) && styles.saveButtonDisabled,
|
||||
]}
|
||||
onPress={handleUpdate}
|
||||
disabled={!name.trim() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>Guardar Cambios</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
keyboardAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
formGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
color: '#1a1a1a',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
},
|
||||
errorCard: {
|
||||
backgroundColor: '#fef2f2',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fecaca',
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 14,
|
||||
},
|
||||
deleteButton: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
backgroundColor: '#93c5fd',
|
||||
},
|
||||
saveButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,27 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function StoresLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#fff' },
|
||||
headerTintColor: '#1a1a1a',
|
||||
headerTitleStyle: { fontWeight: '600' },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{ title: 'Mis Tiendas' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="new"
|
||||
options={{ title: 'Nueva Tienda' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[id]"
|
||||
options={{ title: 'Editar Tienda' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -1,301 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { router, Stack } from 'expo-router';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
import { Store } from '@services/api/stores.service';
|
||||
|
||||
export default function StoresScreen() {
|
||||
const {
|
||||
stores,
|
||||
currentStore,
|
||||
hasMore,
|
||||
fetchStores,
|
||||
selectStore,
|
||||
deleteStore,
|
||||
isLoading,
|
||||
} = useStoresStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStores(true);
|
||||
}, [fetchStores]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await fetchStores(true);
|
||||
setRefreshing(false);
|
||||
}, [fetchStores]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore && !isLoading) {
|
||||
fetchStores(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectStore = (store: Store) => {
|
||||
selectStore(store);
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleDeleteStore = (store: Store) => {
|
||||
Alert.alert(
|
||||
'Eliminar Tienda',
|
||||
`Estas seguro de eliminar "${store.name}"? Esta accion no se puede deshacer.`,
|
||||
[
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Eliminar',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const success = await deleteStore(store.id);
|
||||
if (success) {
|
||||
Alert.alert('Listo', 'La tienda ha sido eliminada');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const renderStore = ({ item }: { item: Store }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.storeCard,
|
||||
currentStore?.id === item.id && styles.storeCardActive,
|
||||
]}
|
||||
onPress={() => handleSelectStore(item)}
|
||||
onLongPress={() => handleDeleteStore(item)}
|
||||
>
|
||||
<View style={styles.storeIcon}>
|
||||
<Text style={styles.storeIconText}>🏪</Text>
|
||||
</View>
|
||||
<View style={styles.storeInfo}>
|
||||
<Text style={styles.storeName}>{item.name}</Text>
|
||||
{item.address && (
|
||||
<Text style={styles.storeAddress} numberOfLines={1}>
|
||||
{item.address}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{currentStore?.id === item.id && (
|
||||
<View style={styles.activeBadge}>
|
||||
<Text style={styles.activeBadgeText}>Activa</Text>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.editButton}
|
||||
onPress={() => router.push(`/stores/${item.id}`)}
|
||||
>
|
||||
<Text style={styles.editButtonText}>✏️</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const EmptyState = () => (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>🏪</Text>
|
||||
<Text style={styles.emptyTitle}>Sin tiendas</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
Crea tu primera tienda para comenzar a usar MiInventario
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.emptyButton}
|
||||
onPress={() => router.push('/stores/new')}
|
||||
>
|
||||
<Text style={styles.emptyButtonText}>Crear Tienda</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => router.push('/stores/new')}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+ Nueva</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isLoading && stores.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
<Text style={styles.loadingText}>Cargando tiendas...</Text>
|
||||
</View>
|
||||
) : stores.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.hint}>
|
||||
Toca para seleccionar, manten presionado para eliminar
|
||||
</Text>
|
||||
<FlatList
|
||||
data={stores}
|
||||
renderItem={renderStore}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
isLoading ? (
|
||||
<ActivityIndicator style={styles.footerLoader} color="#2563eb" />
|
||||
) : null
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
addButton: {
|
||||
marginRight: 8,
|
||||
},
|
||||
addButtonText: {
|
||||
color: '#2563eb',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
hint: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
storeCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
storeCardActive: {
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: '#f0f9ff',
|
||||
},
|
||||
storeIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f5f5f5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
storeIconText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
storeInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
storeName: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
storeAddress: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
activeBadge: {
|
||||
backgroundColor: '#dcfce7',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
marginRight: 8,
|
||||
},
|
||||
activeBadgeText: {
|
||||
color: '#16a34a',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
editButton: {
|
||||
padding: 8,
|
||||
},
|
||||
editButtonText: {
|
||||
fontSize: 18,
|
||||
},
|
||||
separator: {
|
||||
height: 8,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyDescription: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
},
|
||||
emptyButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footerLoader: {
|
||||
paddingVertical: 16,
|
||||
},
|
||||
});
|
||||
@ -1,221 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useState } from 'react';
|
||||
import { router } from 'expo-router';
|
||||
import { useStoresStore } from '@stores/stores.store';
|
||||
|
||||
export default function NewStoreScreen() {
|
||||
const { createStore, isLoading, error } = useStoresStore();
|
||||
const [name, setName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [giro, setGiro] = useState('');
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Error', 'El nombre de la tienda es requerido');
|
||||
return;
|
||||
}
|
||||
|
||||
const store = await createStore({
|
||||
name: name.trim(),
|
||||
address: address.trim() || undefined,
|
||||
city: city.trim() || undefined,
|
||||
giro: giro.trim() || undefined,
|
||||
});
|
||||
|
||||
if (store) {
|
||||
Alert.alert('Listo', 'Tu tienda ha sido creada', [
|
||||
{ text: 'OK', onPress: () => router.back() },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoid}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>Nombre de la tienda *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Ej: Mi Tiendita Centro"
|
||||
placeholderTextColor="#999"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>Direccion</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Ej: Av. Juarez 123, Col. Centro"
|
||||
placeholderTextColor="#999"
|
||||
value={address}
|
||||
onChangeText={setAddress}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>Ciudad</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Ej: Ciudad de Mexico"
|
||||
placeholderTextColor="#999"
|
||||
value={city}
|
||||
onChangeText={setCity}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>Giro</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Ej: Abarrotes, Miscelanea..."
|
||||
placeholderTextColor="#999"
|
||||
value={giro}
|
||||
onChangeText={setGiro}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorCard}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.infoCard}>
|
||||
<Text style={styles.infoIcon}>💡</Text>
|
||||
<Text style={styles.infoText}>
|
||||
Puedes agregar mas tiendas despues desde tu perfil. Cada tienda
|
||||
tiene su propio inventario independiente.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.createButton,
|
||||
(!name.trim() || isLoading) && styles.createButtonDisabled,
|
||||
]}
|
||||
onPress={handleCreate}
|
||||
disabled={!name.trim() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.createButtonText}>Crear Tienda</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
keyboardAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
formGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
color: '#1a1a1a',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
},
|
||||
errorCard: {
|
||||
backgroundColor: '#fef2f2',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#fecaca',
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 14,
|
||||
},
|
||||
infoCard: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#f0f9ff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#bfdbfe',
|
||||
},
|
||||
infoIcon: {
|
||||
fontSize: 20,
|
||||
marginRight: 12,
|
||||
},
|
||||
infoText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#1e40af',
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
},
|
||||
createButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
createButtonDisabled: {
|
||||
backgroundColor: '#93c5fd',
|
||||
},
|
||||
createButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,317 +0,0 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Linking,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Stack, router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '@stores/auth.store';
|
||||
|
||||
type ContactMethod = 'whatsapp' | 'email' | 'form';
|
||||
|
||||
export default function SupportScreen() {
|
||||
const { user } = useAuthStore();
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
const handleWhatsApp = () => {
|
||||
const phone = '5215512345678'; // Replace with actual support number
|
||||
const text = `Hola, necesito ayuda con MiInventario.\n\nUsuario: ${user?.name}\nTelefono: ${user?.phone}`;
|
||||
Linking.openURL(`whatsapp://send?phone=${phone}&text=${encodeURIComponent(text)}`);
|
||||
};
|
||||
|
||||
const handleEmail = () => {
|
||||
const email = 'soporte@miinventario.com';
|
||||
const emailSubject = 'Soporte MiInventario';
|
||||
const body = `\n\n---\nUsuario: ${user?.name}\nTelefono: ${user?.phone}`;
|
||||
Linking.openURL(`mailto:${email}?subject=${encodeURIComponent(emailSubject)}&body=${encodeURIComponent(body)}`);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!subject.trim() || !message.trim()) {
|
||||
Alert.alert('Error', 'Por favor completa todos los campos');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSending(true);
|
||||
|
||||
// Simulate sending the message
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
setIsSending(false);
|
||||
Alert.alert(
|
||||
'Mensaje Enviado',
|
||||
'Hemos recibido tu mensaje. Te responderemos lo antes posible.',
|
||||
[{ text: 'OK', onPress: () => router.back() }]
|
||||
);
|
||||
};
|
||||
|
||||
const ContactCard = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
}: {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
onPress: () => void;
|
||||
}) => (
|
||||
<TouchableOpacity style={styles.contactCard} onPress={onPress}>
|
||||
<View style={styles.contactIcon}>
|
||||
<Text style={styles.contactIconText}>{icon}</Text>
|
||||
</View>
|
||||
<View style={styles.contactInfo}>
|
||||
<Text style={styles.contactTitle}>{title}</Text>
|
||||
<Text style={styles.contactDescription}>{description}</Text>
|
||||
</View>
|
||||
<Text style={styles.contactArrow}>›</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Soporte',
|
||||
headerShown: true,
|
||||
}}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<ScrollView style={styles.scroll}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Necesitas ayuda?</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Estamos aqui para ayudarte. Elige como prefieres contactarnos.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Contacto Rapido</Text>
|
||||
<View style={styles.contactCards}>
|
||||
<ContactCard
|
||||
icon="💬"
|
||||
title="WhatsApp"
|
||||
description="Respuesta inmediata"
|
||||
onPress={handleWhatsApp}
|
||||
/>
|
||||
<ContactCard
|
||||
icon="📧"
|
||||
title="Email"
|
||||
description="soporte@miinventario.com"
|
||||
onPress={handleEmail}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Enviar Mensaje</Text>
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Asunto</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={subject}
|
||||
onChangeText={setSubject}
|
||||
placeholder="Describe brevemente tu problema"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Mensaje</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
placeholder="Cuentanos con detalle como podemos ayudarte..."
|
||||
multiline
|
||||
numberOfLines={5}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, isSending && styles.submitButtonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSending}
|
||||
>
|
||||
{isSending ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>Enviar Mensaje</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.infoIcon}>⏰</Text>
|
||||
<Text style={styles.infoTitle}>Horario de Atencion</Text>
|
||||
<Text style={styles.infoText}>
|
||||
Lunes a Viernes: 9:00 AM - 6:00 PM{'\n'}
|
||||
Sabado: 9:00 AM - 2:00 PM
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
padding: 20,
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 12,
|
||||
},
|
||||
contactCards: {
|
||||
gap: 12,
|
||||
},
|
||||
contactCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
contactIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#f0f7ff',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
contactIconText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
contactInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
contactTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 2,
|
||||
},
|
||||
contactDescription: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
},
|
||||
contactArrow: {
|
||||
fontSize: 20,
|
||||
color: '#ccc',
|
||||
},
|
||||
form: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 120,
|
||||
paddingTop: 12,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
infoSection: {
|
||||
margin: 16,
|
||||
padding: 20,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoIcon: {
|
||||
fontSize: 32,
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@ -1,27 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ValidationLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: true,
|
||||
headerBackTitle: 'Atras',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="items"
|
||||
options={{
|
||||
title: 'Validar Productos',
|
||||
headerBackVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="complete"
|
||||
options={{
|
||||
title: 'Validacion Completada',
|
||||
headerBackVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -1,165 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useValidationsStore } from '../../stores/validations.store';
|
||||
|
||||
export default function ValidationCompleteScreen() {
|
||||
const router = useRouter();
|
||||
const { creditsRewarded, reset } = useValidationsStore();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
reset();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleContinue = () => {
|
||||
router.replace('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="checkmark-circle" size={80} color="#28a745" />
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>Gracias!</Text>
|
||||
<Text style={styles.subtitle}>Tu validacion nos ayuda a mejorar</Text>
|
||||
|
||||
{creditsRewarded !== null && creditsRewarded > 0 && (
|
||||
<View style={styles.rewardCard}>
|
||||
<Ionicons name="gift" size={32} color="#f0ad4e" />
|
||||
<View style={styles.rewardInfo}>
|
||||
<Text style={styles.rewardLabel}>Recompensa</Text>
|
||||
<Text style={styles.rewardValue}>+{creditsRewarded} credito</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.benefits}>
|
||||
<Text style={styles.benefitsTitle}>Con tu ayuda:</Text>
|
||||
<View style={styles.benefitItem}>
|
||||
<Ionicons name="checkmark" size={20} color="#28a745" />
|
||||
<Text style={styles.benefitText}>
|
||||
Mejoramos la deteccion de productos
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Ionicons name="checkmark" size={20} color="#28a745" />
|
||||
<Text style={styles.benefitText}>
|
||||
Entrenamos mejor nuestros modelos
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.benefitItem}>
|
||||
<Ionicons name="checkmark" size={20} color="#28a745" />
|
||||
<Text style={styles.benefitText}>
|
||||
Tu inventario sera mas preciso
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity style={styles.button} onPress={handleContinue}>
|
||||
<Text style={styles.buttonText}>Continuar</Text>
|
||||
<Ionicons name="arrow-forward" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
backgroundColor: '#e8f5e9',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
rewardCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff8e1',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 32,
|
||||
width: '100%',
|
||||
gap: 16,
|
||||
},
|
||||
rewardInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
rewardLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
rewardValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#f0ad4e',
|
||||
},
|
||||
benefits: {
|
||||
width: '100%',
|
||||
},
|
||||
benefitsTitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 12,
|
||||
},
|
||||
benefitItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
benefitText: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
gap: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,301 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useValidationsStore } from '../../stores/validations.store';
|
||||
import { ValidationItemCard } from '../../components/validation/ValidationItemCard';
|
||||
import { ValidationProgressBar } from '../../components/validation/ValidationProgressBar';
|
||||
import { ValidationItemResponse } from '../../services/api/validations.service';
|
||||
|
||||
export default function ValidationItemsScreen() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
pendingRequest,
|
||||
items,
|
||||
responses,
|
||||
currentItemIndex,
|
||||
isLoading,
|
||||
error,
|
||||
addResponse,
|
||||
nextItem,
|
||||
previousItem,
|
||||
submitValidation,
|
||||
skipValidation,
|
||||
} = useValidationsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingRequest || items.length === 0) {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [pendingRequest, items]);
|
||||
|
||||
const handleResponse = (response: Omit<ValidationItemResponse, 'inventoryItemId'>) => {
|
||||
if (!items[currentItemIndex]) return;
|
||||
|
||||
addResponse({
|
||||
...response,
|
||||
inventoryItemId: items[currentItemIndex].id,
|
||||
});
|
||||
|
||||
// Auto-advance to next item
|
||||
if (currentItemIndex < items.length - 1) {
|
||||
setTimeout(() => nextItem(), 300);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (responses.length < items.length) {
|
||||
Alert.alert(
|
||||
'Faltan items',
|
||||
'Por favor valida todos los productos antes de continuar.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await submitValidation();
|
||||
router.replace('/validation/complete');
|
||||
} catch {
|
||||
Alert.alert('Error', 'No se pudo enviar la validacion. Intenta de nuevo.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
Alert.alert(
|
||||
'Omitir validacion',
|
||||
'Estas seguro? No recibiras el credito de recompensa.',
|
||||
[
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Omitir',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await skipValidation();
|
||||
router.replace('/');
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
if (!pendingRequest || items.length === 0) {
|
||||
return (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const currentItem = items[currentItemIndex];
|
||||
const currentResponse = responses.find(
|
||||
(r) => r.inventoryItemId === currentItem?.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ValidationProgressBar
|
||||
current={currentItemIndex}
|
||||
total={items.length}
|
||||
validated={responses.length}
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
|
||||
{currentItem && (
|
||||
<ValidationItemCard
|
||||
item={currentItem}
|
||||
onResponse={handleResponse}
|
||||
existingResponse={currentResponse}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.navigation}>
|
||||
<TouchableOpacity
|
||||
style={[styles.navButton, currentItemIndex === 0 && styles.navButtonDisabled]}
|
||||
onPress={previousItem}
|
||||
disabled={currentItemIndex === 0}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color={currentItemIndex === 0 ? '#ccc' : '#007AFF'}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.navText,
|
||||
currentItemIndex === 0 && styles.navTextDisabled,
|
||||
]}
|
||||
>
|
||||
Anterior
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.navButton,
|
||||
currentItemIndex === items.length - 1 && styles.navButtonDisabled,
|
||||
]}
|
||||
onPress={nextItem}
|
||||
disabled={currentItemIndex === items.length - 1}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.navText,
|
||||
currentItemIndex === items.length - 1 && styles.navTextDisabled,
|
||||
]}
|
||||
>
|
||||
Siguiente
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={24}
|
||||
color={currentItemIndex === items.length - 1 ? '#ccc' : '#007AFF'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
||||
<Text style={styles.skipText}>Omitir</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.submitButton,
|
||||
responses.length < items.length && styles.submitButtonDisabled,
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading || responses.length < items.length}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.submitText}>Enviar</Text>
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>
|
||||
{responses.length}/{items.length}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
loading: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: '#ffebee',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: '#dc3545',
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
padding: 16,
|
||||
},
|
||||
navigation: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
navButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
gap: 4,
|
||||
},
|
||||
navButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
navText: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
navTextDisabled: {
|
||||
color: '#ccc',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
skipButton: {
|
||||
flex: 1,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f5f5f5',
|
||||
alignItems: 'center',
|
||||
},
|
||||
skipText: {
|
||||
color: '#666',
|
||||
fontWeight: '600',
|
||||
},
|
||||
submitButton: {
|
||||
flex: 2,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#007AFF',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
backgroundColor: '#ccc',
|
||||
},
|
||||
submitText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: 'rgba(255,255,255,0.3)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,57 +0,0 @@
|
||||
import React from 'react';
|
||||
import { TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFeedbackStore } from '../../stores/feedback.store';
|
||||
|
||||
interface Props {
|
||||
storeId: string;
|
||||
itemId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmItemButton({ storeId, itemId, onSuccess }: Props) {
|
||||
const { confirmItem, isLoading } = useFeedbackStore();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
await confirmItem(storeId, itemId);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#28a745" size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#28a745" />
|
||||
<Text style={styles.text}>Confirmar</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#e8f5e9',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
gap: 8,
|
||||
},
|
||||
text: {
|
||||
color: '#28a745',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@ -1,203 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useFeedbackStore } from '../../stores/feedback.store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
storeId: string;
|
||||
itemId: string;
|
||||
currentQuantity: number;
|
||||
itemName: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function CorrectQuantityModal({
|
||||
visible,
|
||||
onClose,
|
||||
storeId,
|
||||
itemId,
|
||||
currentQuantity,
|
||||
itemName,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const [quantity, setQuantity] = useState(currentQuantity.toString());
|
||||
const [reason, setReason] = useState('');
|
||||
const { correctQuantity, isLoading, error } = useFeedbackStore();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const newQuantity = parseInt(quantity, 10);
|
||||
if (isNaN(newQuantity) || newQuantity < 0) return;
|
||||
|
||||
try {
|
||||
await correctQuantity(storeId, itemId, {
|
||||
quantity: newQuantity,
|
||||
reason: reason || undefined,
|
||||
});
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setQuantity(currentQuantity.toString());
|
||||
setReason('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide">
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Corregir Cantidad</Text>
|
||||
<Text style={styles.subtitle}>{itemName}</Text>
|
||||
|
||||
<View style={styles.currentValue}>
|
||||
<Text style={styles.label}>Cantidad actual:</Text>
|
||||
<Text style={styles.value}>{currentQuantity}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.inputLabel}>Nueva cantidad:</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={quantity}
|
||||
onChangeText={setQuantity}
|
||||
keyboardType="numeric"
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>Razon (opcional):</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
placeholder="Por que haces esta correccion?"
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
/>
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={handleClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancelar</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.submitButton]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.submitText}>Guardar</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 16,
|
||||
},
|
||||
currentValue: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 8,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
textArea: {
|
||||
height: 60,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
error: {
|
||||
color: '#dc3545',
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
cancelText: {
|
||||
color: '#333',
|
||||
fontWeight: '600',
|
||||
},
|
||||
submitText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,220 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useFeedbackStore } from '../../stores/feedback.store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
storeId: string;
|
||||
itemId: string;
|
||||
currentName: string;
|
||||
currentCategory?: string;
|
||||
currentBarcode?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function CorrectSkuModal({
|
||||
visible,
|
||||
onClose,
|
||||
storeId,
|
||||
itemId,
|
||||
currentName,
|
||||
currentCategory,
|
||||
currentBarcode,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const [name, setName] = useState(currentName);
|
||||
const [category, setCategory] = useState(currentCategory || '');
|
||||
const [barcode, setBarcode] = useState(currentBarcode || '');
|
||||
const [reason, setReason] = useState('');
|
||||
const { correctSku, isLoading, error } = useFeedbackStore();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
try {
|
||||
await correctSku(storeId, itemId, {
|
||||
name: name.trim(),
|
||||
category: category.trim() || undefined,
|
||||
barcode: barcode.trim() || undefined,
|
||||
reason: reason.trim() || undefined,
|
||||
});
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName(currentName);
|
||||
setCategory(currentCategory || '');
|
||||
setBarcode(currentBarcode || '');
|
||||
setReason('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide">
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Corregir Producto</Text>
|
||||
|
||||
<View style={styles.currentValue}>
|
||||
<Text style={styles.label}>Nombre actual:</Text>
|
||||
<Text style={styles.value}>{currentName}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.inputLabel}>Nombre correcto:</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Nombre del producto"
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>Categoria (opcional):</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={category}
|
||||
onChangeText={setCategory}
|
||||
placeholder="Categoria"
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>Codigo de barras (opcional):</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={barcode}
|
||||
onChangeText={setBarcode}
|
||||
placeholder="Codigo de barras"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>Razon (opcional):</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
placeholder="Por que haces esta correccion?"
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
/>
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={handleClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancelar</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.submitButton]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isLoading || !name.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.submitText}>Guardar</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
},
|
||||
currentValue: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
textArea: {
|
||||
height: 60,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
error: {
|
||||
color: '#dc3545',
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
cancelText: {
|
||||
color: '#333',
|
||||
fontWeight: '600',
|
||||
},
|
||||
submitText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,147 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CorrectionHistoryItem } from '../../services/api/feedback.service';
|
||||
|
||||
interface Props {
|
||||
correction: CorrectionHistoryItem;
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
QUANTITY: 'calculator',
|
||||
SKU: 'pricetag',
|
||||
CONFIRMATION: 'checkmark-circle',
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
QUANTITY: 'Cantidad',
|
||||
SKU: 'Nombre/SKU',
|
||||
CONFIRMATION: 'Confirmacion',
|
||||
};
|
||||
|
||||
export function CorrectionHistoryCard({ correction }: Props) {
|
||||
const icon = typeIcons[correction.type] || 'create';
|
||||
const label = typeLabels[correction.type] || correction.type;
|
||||
const date = new Date(correction.createdAt);
|
||||
|
||||
const renderChange = () => {
|
||||
if (correction.type === 'CONFIRMATION') {
|
||||
return <Text style={styles.change}>Item confirmado como correcto</Text>;
|
||||
}
|
||||
|
||||
if (correction.type === 'QUANTITY') {
|
||||
return (
|
||||
<Text style={styles.change}>
|
||||
{correction.previousValue.quantity} → {correction.newValue.quantity}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (correction.type === 'SKU') {
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.change}>
|
||||
"{correction.previousValue.name}" → "{correction.newValue.name}"
|
||||
</Text>
|
||||
{correction.newValue.category !== correction.previousValue.category && (
|
||||
<Text style={styles.subChange}>
|
||||
Categoria: {correction.newValue.category}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name={icon} size={20} color="#007AFF" />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.date}>
|
||||
{date.toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
{renderChange()}
|
||||
{correction.reason && (
|
||||
<Text style={styles.reason}>"{correction.reason}"</Text>
|
||||
)}
|
||||
{correction.user && (
|
||||
<Text style={styles.user}>Por: {correction.user.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#e3f2fd',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
date: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
change: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
subChange: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 2,
|
||||
},
|
||||
reason: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
fontStyle: 'italic',
|
||||
marginTop: 4,
|
||||
},
|
||||
user: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
@ -1,137 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Skeleton, SkeletonText } from '../ui/Skeleton';
|
||||
import { useTheme } from '../../theme/ThemeContext';
|
||||
|
||||
/**
|
||||
* Skeleton para tarjeta de balance de créditos
|
||||
*/
|
||||
export function CreditBalanceSkeleton() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.balanceCard, { backgroundColor: colors.primary }]}>
|
||||
<SkeletonText width={100} height={14} style={{ opacity: 0.5 }} />
|
||||
<Skeleton width={120} height={40} borderRadius={8} style={{ marginTop: 8 }} />
|
||||
<View style={styles.balanceStats}>
|
||||
<View style={styles.balanceStat}>
|
||||
<SkeletonText width={50} height={12} style={{ opacity: 0.5 }} />
|
||||
<SkeletonText width={40} height={16} style={{ marginTop: 4, opacity: 0.5 }} />
|
||||
</View>
|
||||
<View style={styles.balanceStat}>
|
||||
<SkeletonText width={50} height={12} style={{ opacity: 0.5 }} />
|
||||
<SkeletonText width={40} height={16} style={{ marginTop: 4, opacity: 0.5 }} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton para transacción
|
||||
*/
|
||||
export function TransactionSkeleton() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.transaction, { borderBottomColor: colors.border }]}>
|
||||
<View style={styles.transactionIcon}>
|
||||
<Skeleton width={40} height={40} borderRadius={20} />
|
||||
</View>
|
||||
<View style={styles.transactionContent}>
|
||||
<SkeletonText width="60%" height={14} />
|
||||
<SkeletonText width="40%" height={12} style={{ marginTop: 4 }} />
|
||||
</View>
|
||||
<SkeletonText width={50} height={16} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de skeletons de transacciones
|
||||
*/
|
||||
export function TransactionListSkeleton({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<View>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<TransactionSkeleton key={index} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton para paquete de créditos
|
||||
*/
|
||||
export function CreditPackageSkeleton() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.package, { backgroundColor: colors.card }]}>
|
||||
<Skeleton width={48} height={48} borderRadius={24} />
|
||||
<View style={styles.packageContent}>
|
||||
<SkeletonText width={80} height={18} />
|
||||
<SkeletonText width={60} height={12} style={{ marginTop: 4 }} />
|
||||
</View>
|
||||
<SkeletonText width={70} height={24} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de skeletons de paquetes
|
||||
*/
|
||||
export function CreditPackageListSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<View style={styles.packageList}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<CreditPackageSkeleton key={index} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
balanceCard: {
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
margin: 16,
|
||||
},
|
||||
balanceStats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
marginTop: 20,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
balanceStat: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
transaction: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
transactionIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
transactionContent: {
|
||||
flex: 1,
|
||||
},
|
||||
package: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
packageContent: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
packageList: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
@ -1,86 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Skeleton, SkeletonText, SkeletonImage } from '../ui/Skeleton';
|
||||
import { useTheme } from '../../theme/ThemeContext';
|
||||
|
||||
/**
|
||||
* Skeleton para un item de inventario
|
||||
*/
|
||||
export function InventoryItemSkeleton() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { borderBottomColor: colors.border }]}>
|
||||
<SkeletonImage width={64} height={64} borderRadius={8} />
|
||||
<View style={styles.content}>
|
||||
<SkeletonText width="70%" height={16} />
|
||||
<SkeletonText width="40%" height={12} style={{ marginTop: 6 }} />
|
||||
<View style={styles.row}>
|
||||
<SkeletonText width={60} height={14} style={{ marginTop: 8 }} />
|
||||
<SkeletonText width={40} height={20} style={{ marginTop: 8 }} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de skeletons de inventario
|
||||
*/
|
||||
export function InventoryListSkeleton({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<View>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<InventoryItemSkeleton key={index} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton para las estadísticas de inventario
|
||||
*/
|
||||
export function InventoryStatsSkeleton() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.statsContainer}>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<View key={index} style={[styles.statCard, { backgroundColor: colors.card }]}>
|
||||
<Skeleton width={48} height={32} borderRadius={8} />
|
||||
<SkeletonText width={60} height={12} style={{ marginTop: 8 }} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
statCard: {
|
||||
width: '47%',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
@ -1,75 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Skeleton, SkeletonText, SkeletonCircle } from '../ui/Skeleton';
|
||||
import { useTheme } from '../../theme/ThemeContext';
|
||||
|
||||
/**
|
||||
* Skeleton para una notificación
|
||||
*/
|
||||
export function NotificationItemSkeleton() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { borderBottomColor: colors.border }]}>
|
||||
<SkeletonCircle size={44} />
|
||||
<View style={styles.content}>
|
||||
<SkeletonText width="80%" height={14} />
|
||||
<SkeletonText width="100%" height={12} style={{ marginTop: 6 }} />
|
||||
<SkeletonText width="30%" height={10} style={{ marginTop: 8 }} />
|
||||
</View>
|
||||
<View style={styles.indicator}>
|
||||
<Skeleton width={8} height={8} borderRadius={4} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de skeletons de notificaciones
|
||||
*/
|
||||
export function NotificationListSkeleton({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<View>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<NotificationItemSkeleton key={index} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton para el header de notificaciones
|
||||
*/
|
||||
export function NotificationHeaderSkeleton() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.header, { backgroundColor: colors.card }]}>
|
||||
<SkeletonText width={120} height={20} />
|
||||
<SkeletonText width={80} height={14} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
indicator: {
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
});
|
||||
@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Skeleton, SkeletonText, SkeletonCircle } from '../ui/Skeleton';
|
||||
import { useTheme } from '../../theme/ThemeContext';
|
||||
|
||||
/**
|
||||
* Skeleton para tarjeta de tienda
|
||||
*/
|
||||
export function StoreCardSkeleton() {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.header}>
|
||||
<SkeletonCircle size={56} />
|
||||
<View style={styles.headerContent}>
|
||||
<SkeletonText width="60%" height={18} />
|
||||
<SkeletonText width="80%" height={12} style={{ marginTop: 6 }} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.stats}>
|
||||
<View style={styles.stat}>
|
||||
<SkeletonText width={40} height={20} />
|
||||
<SkeletonText width={60} height={10} style={{ marginTop: 4 }} />
|
||||
</View>
|
||||
<View style={styles.stat}>
|
||||
<SkeletonText width={40} height={20} />
|
||||
<SkeletonText width={60} height={10} style={{ marginTop: 4 }} />
|
||||
</View>
|
||||
<View style={styles.stat}>
|
||||
<SkeletonText width={40} height={20} />
|
||||
<SkeletonText width={60} height={10} style={{ marginTop: 4 }} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de skeletons de tiendas
|
||||
*/
|
||||
export function StoreListSkeleton({ count = 3 }: { count?: number }) {
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<StoreCardSkeleton key={index} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerContent: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
stats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
stat: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
@ -1,154 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { FlatList, FlatListProps, ViewStyle, RefreshControl } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withDelay,
|
||||
withSpring,
|
||||
FadeIn,
|
||||
SlideInRight,
|
||||
Layout,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../theme/ThemeContext';
|
||||
|
||||
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
|
||||
|
||||
interface AnimatedListProps<T> extends Omit<FlatListProps<T>, 'renderItem'> {
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
/** Delay base entre items en ms */
|
||||
staggerDelay?: number;
|
||||
/** Tipo de animación de entrada */
|
||||
animationType?: 'fade' | 'slide' | 'spring';
|
||||
/** Mostrar animación al refrescar */
|
||||
animateOnRefresh?: boolean;
|
||||
/** Callback de refresh */
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
/** Está refrescando */
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item animado wrapper
|
||||
*/
|
||||
function AnimatedItem({
|
||||
children,
|
||||
index,
|
||||
staggerDelay,
|
||||
animationType,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
index: number;
|
||||
staggerDelay: number;
|
||||
animationType: 'fade' | 'slide' | 'spring';
|
||||
}) {
|
||||
const delay = Math.min(index * staggerDelay, 500); // Cap máximo de delay
|
||||
|
||||
const entering = (() => {
|
||||
switch (animationType) {
|
||||
case 'slide':
|
||||
return SlideInRight.delay(delay).duration(300);
|
||||
case 'spring':
|
||||
return FadeIn.delay(delay).springify();
|
||||
case 'fade':
|
||||
default:
|
||||
return FadeIn.delay(delay).duration(300);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Animated.View entering={entering} layout={Layout.springify()}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FlatList con animaciones de entrada staggered
|
||||
*/
|
||||
export function AnimatedList<T>({
|
||||
data,
|
||||
renderItem,
|
||||
staggerDelay = 50,
|
||||
animationType = 'fade',
|
||||
animateOnRefresh = true,
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
...props
|
||||
}: AnimatedListProps<T>) {
|
||||
const { colors } = useTheme();
|
||||
const [key, setKey] = React.useState(0);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (onRefresh) {
|
||||
await onRefresh();
|
||||
if (animateOnRefresh) {
|
||||
setKey((prev) => prev + 1);
|
||||
}
|
||||
}
|
||||
}, [onRefresh, animateOnRefresh]);
|
||||
|
||||
const animatedRenderItem = useCallback(
|
||||
({ item, index }: { item: T; index: number }) => (
|
||||
<AnimatedItem
|
||||
index={index}
|
||||
staggerDelay={staggerDelay}
|
||||
animationType={animationType}
|
||||
>
|
||||
{renderItem({ item, index })}
|
||||
</AnimatedItem>
|
||||
),
|
||||
[renderItem, staggerDelay, animationType]
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
key={key}
|
||||
data={data}
|
||||
renderItem={animatedRenderItem}
|
||||
refreshControl={
|
||||
onRefresh ? (
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={colors.primary}
|
||||
colors={[colors.primary]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para crear estilos animados de item de lista
|
||||
*/
|
||||
export function useListItemEntering(index: number, baseDelay = 50) {
|
||||
const delay = Math.min(index * baseDelay, 500);
|
||||
|
||||
return FadeIn.delay(delay).duration(300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente para animar un item individual
|
||||
*/
|
||||
export function AnimatedListItem({
|
||||
children,
|
||||
index,
|
||||
baseDelay = 50,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
index: number;
|
||||
baseDelay?: number;
|
||||
style?: ViewStyle;
|
||||
}) {
|
||||
const entering = useListItemEntering(index, baseDelay);
|
||||
|
||||
return (
|
||||
<Animated.View entering={entering} style={style}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@ -1,114 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useIsOffline } from '../../hooks/useNetworkStatus';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
interface OfflineBannerProps {
|
||||
/** Mensaje personalizado */
|
||||
message?: string;
|
||||
/** Mostrar icono de wifi */
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner que aparece cuando no hay conexión a internet
|
||||
* Se muestra en la parte superior de la pantalla con animación slide
|
||||
*/
|
||||
export function OfflineBanner({
|
||||
message = 'Sin conexión a internet',
|
||||
showIcon = true,
|
||||
}: OfflineBannerProps) {
|
||||
const isOffline = useIsOffline();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const translateY = useSharedValue(-100);
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOffline) {
|
||||
translateY.value = withSpring(0, { damping: 15, stiffness: 150 });
|
||||
opacity.value = withTiming(1, { duration: 200 });
|
||||
} else {
|
||||
translateY.value = withTiming(-100, { duration: 300 });
|
||||
opacity.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
}, [isOffline]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
// No renderizar nada si está online
|
||||
if (!isOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{ paddingTop: insets.top + 8 },
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{showIcon && (
|
||||
<Ionicons
|
||||
name="cloud-offline-outline"
|
||||
size={18}
|
||||
color="#FFFFFF"
|
||||
style={styles.icon}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.text}>{message}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente wrapper que incluye el banner offline
|
||||
* Útil para envolver contenido principal de la app
|
||||
*/
|
||||
export function WithOfflineBanner({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<OfflineBanner />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#EF4444', // Rojo para indicar problema
|
||||
zIndex: 9999,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
icon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
text: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,215 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
interpolate,
|
||||
} from 'react-native-reanimated';
|
||||
import { useEffect } from 'react';
|
||||
import { useTheme } from '../../theme/ThemeContext';
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: number | string;
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente base de Skeleton con animación shimmer
|
||||
*/
|
||||
export function Skeleton({
|
||||
width = '100%',
|
||||
height = 16,
|
||||
borderRadius = 4,
|
||||
style
|
||||
}: SkeletonProps) {
|
||||
const { colors } = useTheme();
|
||||
const shimmerValue = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
shimmerValue.value = withRepeat(
|
||||
withTiming(1, { duration: 1200 }),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(shimmerValue.value, [0, 0.5, 1], [0.3, 0.6, 0.3]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: width as any,
|
||||
height,
|
||||
borderRadius,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
animatedStyle,
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton para texto - línea simple
|
||||
*/
|
||||
export function SkeletonText({
|
||||
width = '80%',
|
||||
height = 14,
|
||||
style
|
||||
}: SkeletonProps) {
|
||||
return (
|
||||
<Skeleton
|
||||
width={width}
|
||||
height={height}
|
||||
borderRadius={4}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton circular - para avatares
|
||||
*/
|
||||
export function SkeletonCircle({
|
||||
size = 40,
|
||||
style
|
||||
}: { size?: number; style?: ViewStyle }) {
|
||||
return (
|
||||
<Skeleton
|
||||
width={size}
|
||||
height={size}
|
||||
borderRadius={size / 2}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton para imagen cuadrada
|
||||
*/
|
||||
export function SkeletonImage({
|
||||
width = 80,
|
||||
height = 80,
|
||||
borderRadius = 8,
|
||||
style
|
||||
}: SkeletonProps) {
|
||||
return (
|
||||
<Skeleton
|
||||
width={width}
|
||||
height={height}
|
||||
borderRadius={borderRadius}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton para tarjeta completa
|
||||
*/
|
||||
export function SkeletonCard({ style }: { style?: ViewStyle }) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.card, { backgroundColor: colors.card }, style]}>
|
||||
<View style={styles.cardHeader}>
|
||||
<SkeletonCircle size={48} />
|
||||
<View style={styles.cardHeaderText}>
|
||||
<SkeletonText width="60%" height={16} />
|
||||
<SkeletonText width="40%" height={12} style={{ marginTop: 8 }} />
|
||||
</View>
|
||||
</View>
|
||||
<SkeletonText width="100%" height={14} style={{ marginTop: 16 }} />
|
||||
<SkeletonText width="90%" height={14} style={{ marginTop: 8 }} />
|
||||
<SkeletonText width="70%" height={14} style={{ marginTop: 8 }} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton para item de lista
|
||||
*/
|
||||
export function SkeletonListItem({ style }: { style?: ViewStyle }) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.listItem, { borderBottomColor: colors.border }, style]}>
|
||||
<SkeletonImage width={56} height={56} borderRadius={8} />
|
||||
<View style={styles.listItemContent}>
|
||||
<SkeletonText width="70%" height={16} />
|
||||
<SkeletonText width="50%" height={12} style={{ marginTop: 6 }} />
|
||||
<SkeletonText width="30%" height={12} style={{ marginTop: 6 }} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton para estadística/métrica
|
||||
*/
|
||||
export function SkeletonStat({ style }: { style?: ViewStyle }) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.stat, { backgroundColor: colors.card }, style]}>
|
||||
<SkeletonText width={60} height={28} />
|
||||
<SkeletonText width={80} height={12} style={{ marginTop: 8 }} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grupo de skeletons de lista
|
||||
*/
|
||||
export function SkeletonList({
|
||||
count = 5,
|
||||
style
|
||||
}: { count?: number; style?: ViewStyle }) {
|
||||
return (
|
||||
<View style={style}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<SkeletonListItem key={index} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginVertical: 8,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cardHeaderText: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
stat: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
minWidth: 100,
|
||||
},
|
||||
});
|
||||
@ -1,317 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { ValidationItem, ValidationItemResponse } from '../../services/api/validations.service';
|
||||
|
||||
interface Props {
|
||||
item: ValidationItem;
|
||||
onResponse: (response: Omit<ValidationItemResponse, 'inventoryItemId'>) => void;
|
||||
existingResponse?: ValidationItemResponse;
|
||||
}
|
||||
|
||||
export function ValidationItemCard({ item, onResponse, existingResponse }: Props) {
|
||||
const [showCorrection, setShowCorrection] = useState(false);
|
||||
const [correctedQuantity, setCorrectedQuantity] = useState(
|
||||
existingResponse?.correctedQuantity?.toString() || item.quantity.toString(),
|
||||
);
|
||||
const [correctedName, setCorrectedName] = useState(
|
||||
existingResponse?.correctedName || item.name,
|
||||
);
|
||||
const [startTime] = useState(Date.now());
|
||||
|
||||
const handleCorrect = () => {
|
||||
setShowCorrection(!showCorrection);
|
||||
};
|
||||
|
||||
const handleMarkCorrect = () => {
|
||||
onResponse({
|
||||
isCorrect: true,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitCorrection = () => {
|
||||
const qty = parseInt(correctedQuantity, 10);
|
||||
onResponse({
|
||||
isCorrect: false,
|
||||
correctedQuantity: isNaN(qty) ? undefined : qty,
|
||||
correctedName: correctedName !== item.name ? correctedName : undefined,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
});
|
||||
setShowCorrection(false);
|
||||
};
|
||||
|
||||
const confidence = item.detectionConfidence
|
||||
? Math.round(Number(item.detectionConfidence) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{item.imageUrl && (
|
||||
<Image source={{ uri: item.imageUrl }} style={styles.image} />
|
||||
)}
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.name}>{item.name}</Text>
|
||||
<View style={styles.details}>
|
||||
<Text style={styles.quantity}>Cantidad: {item.quantity}</Text>
|
||||
{item.category && (
|
||||
<Text style={styles.category}>{item.category}</Text>
|
||||
)}
|
||||
</View>
|
||||
{confidence !== null && (
|
||||
<View style={styles.confidenceContainer}>
|
||||
<Text style={styles.confidenceLabel}>Confianza:</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.confidenceBadge,
|
||||
confidence >= 80
|
||||
? styles.highConfidence
|
||||
: confidence >= 60
|
||||
? styles.mediumConfidence
|
||||
: styles.lowConfidence,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.confidenceText}>{confidence}%</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{showCorrection ? (
|
||||
<View style={styles.correctionForm}>
|
||||
<Text style={styles.correctionLabel}>Nombre correcto:</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={correctedName}
|
||||
onChangeText={setCorrectedName}
|
||||
placeholder="Nombre del producto"
|
||||
/>
|
||||
<Text style={styles.correctionLabel}>Cantidad correcta:</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={correctedQuantity}
|
||||
onChangeText={setCorrectedQuantity}
|
||||
keyboardType="numeric"
|
||||
placeholder="0"
|
||||
/>
|
||||
<View style={styles.correctionButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelCorrectionButton}
|
||||
onPress={() => setShowCorrection(false)}
|
||||
>
|
||||
<Text style={styles.cancelCorrectionText}>Cancelar</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.saveCorrectionButton}
|
||||
onPress={handleSubmitCorrection}
|
||||
>
|
||||
<Text style={styles.saveCorrectionText}>Guardar</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.incorrectButton]}
|
||||
onPress={handleCorrect}
|
||||
>
|
||||
<Ionicons name="create-outline" size={20} color="#dc3545" />
|
||||
<Text style={styles.incorrectText}>Corregir</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.correctButton]}
|
||||
onPress={handleMarkCorrect}
|
||||
>
|
||||
<Ionicons name="checkmark" size={20} color="#28a745" />
|
||||
<Text style={styles.correctText}>Correcto</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{existingResponse && !showCorrection && (
|
||||
<View style={styles.responseIndicator}>
|
||||
<Ionicons
|
||||
name={existingResponse.isCorrect ? 'checkmark-circle' : 'pencil'}
|
||||
size={16}
|
||||
color={existingResponse.isCorrect ? '#28a745' : '#f0ad4e'}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.responseText,
|
||||
{ color: existingResponse.isCorrect ? '#28a745' : '#f0ad4e' },
|
||||
]}
|
||||
>
|
||||
{existingResponse.isCorrect ? 'Marcado correcto' : 'Corregido'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: 150,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
content: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
name: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
},
|
||||
details: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
quantity: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
category: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
backgroundColor: '#f0f0f0',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
confidenceContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
gap: 8,
|
||||
},
|
||||
confidenceLabel: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
confidenceBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
highConfidence: {
|
||||
backgroundColor: '#e8f5e9',
|
||||
},
|
||||
mediumConfidence: {
|
||||
backgroundColor: '#fff3e0',
|
||||
},
|
||||
lowConfidence: {
|
||||
backgroundColor: '#ffebee',
|
||||
},
|
||||
confidenceText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
gap: 6,
|
||||
},
|
||||
incorrectButton: {
|
||||
backgroundColor: '#ffebee',
|
||||
},
|
||||
correctButton: {
|
||||
backgroundColor: '#e8f5e9',
|
||||
},
|
||||
incorrectText: {
|
||||
color: '#dc3545',
|
||||
fontWeight: '600',
|
||||
},
|
||||
correctText: {
|
||||
color: '#28a745',
|
||||
fontWeight: '600',
|
||||
},
|
||||
correctionForm: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
paddingTop: 12,
|
||||
},
|
||||
correctionLabel: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
correctionButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
cancelCorrectionButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f5f5f5',
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveCorrectionButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#007AFF',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelCorrectionText: {
|
||||
color: '#666',
|
||||
fontWeight: '600',
|
||||
},
|
||||
saveCorrectionText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
responseIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
gap: 6,
|
||||
},
|
||||
responseText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@ -1,63 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
current: number;
|
||||
total: number;
|
||||
validated: number;
|
||||
}
|
||||
|
||||
export function ValidationProgressBar({ current, total, validated }: Props) {
|
||||
const progress = (validated / total) * 100;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.label}>
|
||||
Producto {current + 1} de {total}
|
||||
</Text>
|
||||
<Text style={styles.validated}>
|
||||
{validated} validados
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.track}>
|
||||
<View style={[styles.fill, { width: `${progress}%` }]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
validated: {
|
||||
fontSize: 14,
|
||||
color: '#28a745',
|
||||
},
|
||||
track: {
|
||||
height: 4,
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 2,
|
||||
},
|
||||
});
|
||||
@ -1,184 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useValidationsStore } from '../../stores/validations.store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
requestId: string;
|
||||
creditsReward: number;
|
||||
itemsCount: number;
|
||||
}
|
||||
|
||||
export function ValidationPromptModal({
|
||||
visible,
|
||||
onClose,
|
||||
requestId,
|
||||
creditsReward,
|
||||
itemsCount,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { skipValidation } = useValidationsStore();
|
||||
|
||||
const handleAccept = () => {
|
||||
onClose();
|
||||
router.push('/validation/items');
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
await skipValidation();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade">
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="checkmark-done-circle" size={48} color="#007AFF" />
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>Ayudanos a mejorar</Text>
|
||||
<Text style={styles.description}>
|
||||
Validando algunos productos nos ayudas a detectar mejor tu inventario
|
||||
en el futuro.
|
||||
</Text>
|
||||
|
||||
<View style={styles.stats}>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="cube-outline" size={24} color="#666" />
|
||||
<Text style={styles.statValue}>{itemsCount}</Text>
|
||||
<Text style={styles.statLabel}>productos</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Ionicons name="gift-outline" size={24} color="#28a745" />
|
||||
<Text style={[styles.statValue, styles.rewardValue]}>
|
||||
+{creditsReward}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>credito</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.time}>Toma menos de 1 minuto</Text>
|
||||
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.skipButton]}
|
||||
onPress={handleSkip}
|
||||
>
|
||||
<Text style={styles.skipText}>Ahora no</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.acceptButton]}
|
||||
onPress={handleAccept}
|
||||
>
|
||||
<Text style={styles.acceptText}>Validar</Text>
|
||||
<Ionicons name="arrow-forward" size={18} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#e3f2fd',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
stats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 40,
|
||||
marginBottom: 16,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 4,
|
||||
},
|
||||
rewardValue: {
|
||||
color: '#28a745',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
time: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 20,
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
skipButton: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
acceptButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
skipText: {
|
||||
color: '#666',
|
||||
fontWeight: '600',
|
||||
},
|
||||
acceptText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@ -1,186 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withSpring,
|
||||
withDelay,
|
||||
Easing,
|
||||
interpolate,
|
||||
WithTimingConfig,
|
||||
WithSpringConfig,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const DEFAULT_TIMING: WithTimingConfig = {
|
||||
duration: 300,
|
||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
||||
};
|
||||
|
||||
const DEFAULT_SPRING: WithSpringConfig = {
|
||||
damping: 15,
|
||||
stiffness: 150,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook para animación de fade in
|
||||
*/
|
||||
export function useFadeIn(delay = 0) {
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withDelay(delay, withTiming(1, DEFAULT_TIMING));
|
||||
}, [delay]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return { animatedStyle, opacity };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para animación de slide desde abajo
|
||||
*/
|
||||
export function useSlideIn(delay = 0, distance = 20) {
|
||||
const opacity = useSharedValue(0);
|
||||
const translateY = useSharedValue(distance);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withDelay(delay, withTiming(1, DEFAULT_TIMING));
|
||||
translateY.value = withDelay(delay, withSpring(0, DEFAULT_SPRING));
|
||||
}, [delay, distance]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
return { animatedStyle, opacity, translateY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para animación de slide desde la derecha
|
||||
*/
|
||||
export function useSlideFromRight(delay = 0, distance = 30) {
|
||||
const opacity = useSharedValue(0);
|
||||
const translateX = useSharedValue(distance);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withDelay(delay, withTiming(1, DEFAULT_TIMING));
|
||||
translateX.value = withDelay(delay, withSpring(0, DEFAULT_SPRING));
|
||||
}, [delay, distance]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
return { animatedStyle, opacity, translateX };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para efecto de escala al presionar
|
||||
*/
|
||||
export function usePressScale(pressedScale = 0.97) {
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const onPressIn = () => {
|
||||
scale.value = withSpring(pressedScale, { damping: 20, stiffness: 300 });
|
||||
};
|
||||
|
||||
const onPressOut = () => {
|
||||
scale.value = withSpring(1, { damping: 20, stiffness: 300 });
|
||||
};
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
return { animatedStyle, onPressIn, onPressOut, scale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para animación stagger en listas
|
||||
* Retorna un delay calculado basado en el índice
|
||||
*/
|
||||
export function useListItemAnimation(index: number, baseDelay = 50) {
|
||||
const delay = index * baseDelay;
|
||||
return useSlideIn(delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para animación de shimmer (skeleton loader)
|
||||
*/
|
||||
export function useShimmer() {
|
||||
const shimmerValue = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
shimmerValue.value = withTiming(1, { duration: 1000 }, () => {
|
||||
shimmerValue.value = 0;
|
||||
animate();
|
||||
});
|
||||
};
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
shimmerValue.value = 0;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(shimmerValue.value, [0, 0.5, 1], [0.3, 0.7, 0.3]),
|
||||
}));
|
||||
|
||||
return { animatedStyle, shimmerValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para animación de pulso
|
||||
*/
|
||||
export function usePulse(minScale = 0.98, maxScale = 1.02) {
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
scale.value = withTiming(maxScale, { duration: 800 }, () => {
|
||||
scale.value = withTiming(minScale, { duration: 800 }, () => {
|
||||
animate();
|
||||
});
|
||||
});
|
||||
};
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
scale.value = 1;
|
||||
};
|
||||
}, [minScale, maxScale]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
return { animatedStyle, scale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para animar entrada/salida de un elemento
|
||||
*/
|
||||
export function useToggleAnimation(isVisible: boolean) {
|
||||
const opacity = useSharedValue(isVisible ? 1 : 0);
|
||||
const translateY = useSharedValue(isVisible ? 0 : -20);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(isVisible ? 1 : 0, { duration: 200 });
|
||||
translateY.value = withSpring(isVisible ? 0 : -20);
|
||||
}, [isVisible]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
return { animatedStyle };
|
||||
}
|
||||
|
||||
export { Animated };
|
||||
@ -1,73 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import NetInfo, { NetInfoState, NetInfoStateType } from '@react-native-community/netinfo';
|
||||
|
||||
export interface NetworkStatus {
|
||||
isConnected: boolean;
|
||||
isInternetReachable: boolean | null;
|
||||
type: NetInfoStateType;
|
||||
isWifi: boolean;
|
||||
isCellular: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para detectar el estado de la conexión de red
|
||||
*/
|
||||
export function useNetworkStatus(): NetworkStatus {
|
||||
const [networkStatus, setNetworkStatus] = useState<NetworkStatus>({
|
||||
isConnected: true,
|
||||
isInternetReachable: true,
|
||||
type: NetInfoStateType.unknown,
|
||||
isWifi: false,
|
||||
isCellular: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
|
||||
setNetworkStatus({
|
||||
isConnected: state.isConnected ?? false,
|
||||
isInternetReachable: state.isInternetReachable,
|
||||
type: state.type,
|
||||
isWifi: state.type === NetInfoStateType.wifi,
|
||||
isCellular: state.type === NetInfoStateType.cellular,
|
||||
});
|
||||
});
|
||||
|
||||
// Obtener estado inicial
|
||||
NetInfo.fetch().then((state) => {
|
||||
setNetworkStatus({
|
||||
isConnected: state.isConnected ?? false,
|
||||
isInternetReachable: state.isInternetReachable,
|
||||
type: state.type,
|
||||
isWifi: state.type === NetInfoStateType.wifi,
|
||||
isCellular: state.type === NetInfoStateType.cellular,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return networkStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook simplificado para verificar si hay conexión
|
||||
*/
|
||||
export function useIsOnline(): boolean {
|
||||
const { isConnected, isInternetReachable } = useNetworkStatus();
|
||||
|
||||
// isInternetReachable puede ser null mientras se determina
|
||||
if (isInternetReachable === null) {
|
||||
return isConnected;
|
||||
}
|
||||
|
||||
return isConnected && isInternetReachable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook simplificado para verificar si está offline
|
||||
*/
|
||||
export function useIsOffline(): boolean {
|
||||
return !useIsOnline();
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
import { authService } from '../auth.service';
|
||||
import apiClient from '../client';
|
||||
|
||||
jest.mock('../client');
|
||||
|
||||
const mockApiClient = apiClient as jest.Mocked<typeof apiClient>;
|
||||
|
||||
describe('Auth Service', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should call login endpoint with credentials', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
user: { id: '1', phone: '+1234567890', name: 'Test' },
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
},
|
||||
};
|
||||
|
||||
mockApiClient.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.login({
|
||||
phone: '+1234567890',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith('/auth/login', {
|
||||
phone: '+1234567890',
|
||||
password: 'password123',
|
||||
});
|
||||
expect(result).toEqual(mockResponse.data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initiateRegistration', () => {
|
||||
it('should call registration endpoint', async () => {
|
||||
mockApiClient.post.mockResolvedValue({ data: { message: 'OTP sent' } });
|
||||
|
||||
await authService.initiateRegistration({
|
||||
phone: '+1234567890',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith('/auth/register/initiate', {
|
||||
phone: '+1234567890',
|
||||
name: 'Test User',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyOtp', () => {
|
||||
it('should call OTP verification endpoint', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
user: { id: '1', phone: '+1234567890', name: 'Test' },
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
},
|
||||
};
|
||||
|
||||
mockApiClient.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.verifyOtp({
|
||||
phone: '+1234567890',
|
||||
otp: '123456',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith('/auth/register/verify', {
|
||||
phone: '+1234567890',
|
||||
otp: '123456',
|
||||
password: 'password123',
|
||||
});
|
||||
expect(result).toEqual(mockResponse.data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshTokens', () => {
|
||||
it('should call refresh endpoint', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
},
|
||||
};
|
||||
|
||||
mockApiClient.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.refreshTokens('old-refresh-token');
|
||||
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith('/auth/refresh', {
|
||||
refreshToken: 'old-refresh-token',
|
||||
});
|
||||
expect(result).toEqual(mockResponse.data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should call logout endpoint', async () => {
|
||||
mockApiClient.post.mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await authService.logout('refresh-token');
|
||||
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith('/auth/logout', {
|
||||
refreshToken: 'refresh-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,119 +0,0 @@
|
||||
import { inventoryService } from '../inventory.service';
|
||||
import apiClient from '../client';
|
||||
|
||||
jest.mock('../client');
|
||||
|
||||
const mockApiClient = apiClient as jest.Mocked<typeof apiClient>;
|
||||
|
||||
describe('Inventory Service', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getItems', () => {
|
||||
it('should fetch items with pagination', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
items: [
|
||||
{ id: '1', name: 'Item 1', quantity: 10 },
|
||||
{ id: '2', name: 'Item 2', quantity: 5 },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockApiClient.get.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await inventoryService.getItems('store-1', { page: 1, limit: 50 });
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory', {
|
||||
params: { page: 1, limit: 50 },
|
||||
});
|
||||
expect(result).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('should pass category filter', async () => {
|
||||
mockApiClient.get.mockResolvedValue({
|
||||
data: { items: [], total: 0, page: 1, limit: 50, hasMore: false },
|
||||
});
|
||||
|
||||
await inventoryService.getItems('store-1', { category: 'Electronics' });
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory', {
|
||||
params: { category: 'Electronics' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getItem', () => {
|
||||
it('should fetch single item', async () => {
|
||||
const mockItem = { id: '1', name: 'Item 1', quantity: 10 };
|
||||
mockApiClient.get.mockResolvedValue({ data: mockItem });
|
||||
|
||||
const result = await inventoryService.getItem('store-1', '1');
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory/1');
|
||||
expect(result).toEqual(mockItem);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateItem', () => {
|
||||
it('should send PATCH request with updates', async () => {
|
||||
const updatedItem = { id: '1', name: 'Updated', quantity: 20 };
|
||||
mockApiClient.patch.mockResolvedValue({ data: updatedItem });
|
||||
|
||||
const result = await inventoryService.updateItem('store-1', '1', {
|
||||
name: 'Updated',
|
||||
quantity: 20,
|
||||
});
|
||||
|
||||
expect(mockApiClient.patch).toHaveBeenCalledWith('/stores/store-1/inventory/1', {
|
||||
name: 'Updated',
|
||||
quantity: 20,
|
||||
});
|
||||
expect(result).toEqual(updatedItem);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteItem', () => {
|
||||
it('should send DELETE request', async () => {
|
||||
mockApiClient.delete.mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await inventoryService.deleteItem('store-1', '1');
|
||||
|
||||
expect(mockApiClient.delete).toHaveBeenCalledWith('/stores/store-1/inventory/1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should fetch inventory statistics', async () => {
|
||||
const mockStats = {
|
||||
totalItems: 100,
|
||||
totalValue: 5000,
|
||||
lowStockCount: 5,
|
||||
categoryBreakdown: [],
|
||||
};
|
||||
mockApiClient.get.mockResolvedValue({ data: mockStats });
|
||||
|
||||
const result = await inventoryService.getStatistics('store-1');
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory/statistics');
|
||||
expect(result).toEqual(mockStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategories', () => {
|
||||
it('should fetch categories list', async () => {
|
||||
const mockCategories = ['Electronics', 'Clothing', 'Food'];
|
||||
mockApiClient.get.mockResolvedValue({ data: mockCategories });
|
||||
|
||||
const result = await inventoryService.getCategories('store-1');
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/inventory/categories');
|
||||
expect(result).toEqual(mockCategories);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,175 +0,0 @@
|
||||
import { reportsService } from '../reports.service';
|
||||
import apiClient from '../client';
|
||||
|
||||
jest.mock('../client');
|
||||
|
||||
const mockApiClient = apiClient as jest.Mocked<typeof apiClient>;
|
||||
|
||||
describe('Reports Service', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getValuationReport', () => {
|
||||
it('should fetch valuation report', async () => {
|
||||
const mockReport = {
|
||||
summary: {
|
||||
totalItems: 100,
|
||||
totalCost: 1000,
|
||||
totalPrice: 2000,
|
||||
potentialMargin: 1000,
|
||||
potentialMarginPercent: 50,
|
||||
},
|
||||
byCategory: [],
|
||||
items: [],
|
||||
};
|
||||
|
||||
mockApiClient.get.mockResolvedValue({ data: mockReport });
|
||||
|
||||
const result = await reportsService.getValuationReport('store-1');
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/valuation');
|
||||
expect(result).toEqual(mockReport);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMovementsReport', () => {
|
||||
it('should fetch movements report without params', async () => {
|
||||
const mockReport = {
|
||||
summary: {
|
||||
period: { start: '2024-01-01', end: '2024-01-31' },
|
||||
totalMovements: 50,
|
||||
netChange: 10,
|
||||
itemsIncreased: 30,
|
||||
itemsDecreased: 20,
|
||||
},
|
||||
movements: [],
|
||||
byItem: [],
|
||||
total: 50,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
hasMore: false,
|
||||
};
|
||||
|
||||
mockApiClient.get.mockResolvedValue({ data: mockReport });
|
||||
|
||||
const result = await reportsService.getMovementsReport('store-1');
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/movements', {
|
||||
params: undefined,
|
||||
});
|
||||
expect(result).toEqual(mockReport);
|
||||
});
|
||||
|
||||
it('should pass date range params', async () => {
|
||||
mockApiClient.get.mockResolvedValue({
|
||||
data: {
|
||||
summary: {},
|
||||
movements: [],
|
||||
byItem: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
hasMore: false,
|
||||
},
|
||||
});
|
||||
|
||||
await reportsService.getMovementsReport('store-1', {
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
});
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/movements', {
|
||||
params: {
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass pagination params', async () => {
|
||||
mockApiClient.get.mockResolvedValue({
|
||||
data: {
|
||||
summary: {},
|
||||
movements: [],
|
||||
byItem: [],
|
||||
total: 100,
|
||||
page: 2,
|
||||
limit: 20,
|
||||
hasMore: true,
|
||||
},
|
||||
});
|
||||
|
||||
await reportsService.getMovementsReport('store-1', {
|
||||
page: 2,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/movements', {
|
||||
params: { page: 2, limit: 20 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategoriesReport', () => {
|
||||
it('should fetch categories report', async () => {
|
||||
const mockReport = {
|
||||
summary: {
|
||||
totalCategories: 5,
|
||||
totalItems: 100,
|
||||
totalValue: 10000,
|
||||
},
|
||||
categories: [
|
||||
{
|
||||
name: 'Electronics',
|
||||
itemCount: 50,
|
||||
percentOfTotal: 50,
|
||||
totalValue: 5000,
|
||||
lowStockCount: 2,
|
||||
averagePrice: 100,
|
||||
topItems: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockApiClient.get.mockResolvedValue({ data: mockReport });
|
||||
|
||||
const result = await reportsService.getCategoriesReport('store-1');
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/categories');
|
||||
expect(result).toEqual(mockReport);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLowStockReport', () => {
|
||||
it('should fetch low stock report', async () => {
|
||||
const mockReport = {
|
||||
summary: {
|
||||
totalAlerts: 10,
|
||||
criticalCount: 3,
|
||||
warningCount: 7,
|
||||
totalValueAtRisk: 500,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Low Stock Item',
|
||||
category: 'Electronics',
|
||||
quantity: 2,
|
||||
minStock: 10,
|
||||
shortage: 8,
|
||||
estimatedReorderCost: 80,
|
||||
priority: 'critical',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockApiClient.get.mockResolvedValue({ data: mockReport });
|
||||
|
||||
const result = await reportsService.getLowStockReport('store-1');
|
||||
|
||||
expect(mockApiClient.get).toHaveBeenCalledWith('/stores/store-1/reports/low-stock');
|
||||
expect(result).toEqual(mockReport);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,62 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
interface LoginRequest {
|
||||
phone: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface RegisterRequest {
|
||||
phone: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface VerifyOtpRequest {
|
||||
phone: string;
|
||||
otp: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
user: {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
login: async (data: LoginRequest): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/auth/login', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
initiateRegistration: async (data: RegisterRequest): Promise<void> => {
|
||||
await apiClient.post('/auth/register', data);
|
||||
},
|
||||
|
||||
verifyOtp: async (data: VerifyOtpRequest): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/auth/verify-otp', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
refreshTokens: async (refreshToken: string): Promise<TokenResponse> => {
|
||||
const response = await apiClient.post<TokenResponse>('/auth/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
logout: async (refreshToken: string): Promise<void> => {
|
||||
await apiClient.post('/auth/logout', { refreshToken });
|
||||
},
|
||||
};
|
||||
@ -1,58 +0,0 @@
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { useAuthStore } from '@stores/auth.store';
|
||||
|
||||
const API_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:3142/api/v1';
|
||||
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const { accessToken } = useAuthStore.getState();
|
||||
if (accessToken && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor - handle token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
// If 401 and not already retrying, try to refresh token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
await useAuthStore.getState().refreshTokens();
|
||||
const { accessToken } = useAuthStore.getState();
|
||||
|
||||
if (accessToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return apiClient(originalRequest);
|
||||
} catch {
|
||||
// Refresh failed, logout
|
||||
useAuthStore.getState().logout();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@ -1,61 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
interface BalanceResponse {
|
||||
balance: number;
|
||||
totalPurchased: number;
|
||||
totalConsumed: number;
|
||||
totalFromReferrals: number;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: 'purchase' | 'consumption' | 'referral_bonus';
|
||||
amount: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TransactionsResponse {
|
||||
transactions: Transaction[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface PurchaseRequest {
|
||||
packageId: string;
|
||||
paymentMethodId: string;
|
||||
}
|
||||
|
||||
interface PurchaseResponse {
|
||||
transactionId: string;
|
||||
newBalance: number;
|
||||
paymentStatus: 'completed' | 'pending' | 'failed';
|
||||
paymentUrl?: string; // For OXXO/7-Eleven vouchers
|
||||
}
|
||||
|
||||
export const creditsService = {
|
||||
getBalance: async (): Promise<BalanceResponse> => {
|
||||
const response = await apiClient.get<BalanceResponse>('/credits/balance');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTransactions: async (
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<TransactionsResponse> => {
|
||||
const response = await apiClient.get<TransactionsResponse>(
|
||||
'/credits/transactions',
|
||||
{ params: { page, limit } }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
purchase: async (data: PurchaseRequest): Promise<PurchaseResponse> => {
|
||||
const response = await apiClient.post<PurchaseResponse>(
|
||||
'/credits/purchase',
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -1,143 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export type ExportFormat = 'CSV' | 'EXCEL';
|
||||
|
||||
export type ExportType =
|
||||
| 'INVENTORY'
|
||||
| 'REPORT_VALUATION'
|
||||
| 'REPORT_MOVEMENTS'
|
||||
| 'REPORT_CATEGORIES'
|
||||
| 'REPORT_LOW_STOCK';
|
||||
|
||||
export type ExportStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||
|
||||
export interface ExportFilters {
|
||||
category?: string;
|
||||
lowStockOnly?: boolean;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface ExportJobResponse {
|
||||
jobId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ExportStatusResponse {
|
||||
id: string;
|
||||
status: ExportStatus;
|
||||
format: ExportFormat;
|
||||
type: ExportType;
|
||||
filters?: ExportFilters;
|
||||
totalRows?: number;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface ExportDownloadResponse {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export const exportsService = {
|
||||
/**
|
||||
* Request inventory export
|
||||
*/
|
||||
requestInventoryExport: async (
|
||||
storeId: string,
|
||||
format: ExportFormat,
|
||||
filters?: { category?: string; lowStockOnly?: boolean },
|
||||
): Promise<ExportJobResponse> => {
|
||||
const response = await apiClient.post<ExportJobResponse>(
|
||||
`/stores/${storeId}/exports/inventory`,
|
||||
{ format, ...filters },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Request report export
|
||||
*/
|
||||
requestReportExport: async (
|
||||
storeId: string,
|
||||
type: ExportType,
|
||||
format: ExportFormat,
|
||||
filters?: { startDate?: string; endDate?: string },
|
||||
): Promise<ExportJobResponse> => {
|
||||
const response = await apiClient.post<ExportJobResponse>(
|
||||
`/stores/${storeId}/exports/report`,
|
||||
{ type, format, ...filters },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get export status
|
||||
*/
|
||||
getExportStatus: async (
|
||||
storeId: string,
|
||||
jobId: string,
|
||||
): Promise<ExportStatusResponse> => {
|
||||
const response = await apiClient.get<ExportStatusResponse>(
|
||||
`/stores/${storeId}/exports/${jobId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get download URL for completed export
|
||||
*/
|
||||
getDownloadUrl: async (
|
||||
storeId: string,
|
||||
jobId: string,
|
||||
): Promise<ExportDownloadResponse> => {
|
||||
const response = await apiClient.get<ExportDownloadResponse>(
|
||||
`/stores/${storeId}/exports/${jobId}/download`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll export status until complete or failed
|
||||
*/
|
||||
pollExportStatus: async (
|
||||
storeId: string,
|
||||
jobId: string,
|
||||
onProgress?: (status: ExportStatusResponse) => void,
|
||||
maxAttempts = 60,
|
||||
intervalMs = 2000,
|
||||
): Promise<ExportStatusResponse> => {
|
||||
let attempts = 0;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await exportsService.getExportStatus(storeId, jobId);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(status);
|
||||
}
|
||||
|
||||
if (status.status === 'COMPLETED' || status.status === 'FAILED') {
|
||||
resolve(status);
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts >= maxAttempts) {
|
||||
reject(new Error('Export timed out'));
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(poll, intervalMs);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -1,115 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface CorrectQuantityRequest {
|
||||
quantity: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CorrectSkuRequest {
|
||||
name: string;
|
||||
category?: string;
|
||||
barcode?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CorrectionResponse {
|
||||
id: string;
|
||||
type: 'QUANTITY' | 'SKU' | 'CONFIRMATION';
|
||||
previousValue: Record<string, any>;
|
||||
newValue: Record<string, any>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CorrectionHistoryItem {
|
||||
id: string;
|
||||
type: 'QUANTITY' | 'SKU' | 'CONFIRMATION';
|
||||
previousValue: Record<string, any>;
|
||||
newValue: Record<string, any>;
|
||||
reason?: string;
|
||||
createdAt: string;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubmitProductRequest {
|
||||
storeId: string;
|
||||
videoId?: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
barcode?: string;
|
||||
imageUrl?: string;
|
||||
frameTimestamp?: number;
|
||||
boundingBox?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ProductSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
barcode?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const feedbackService = {
|
||||
async correctQuantity(
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
data: CorrectQuantityRequest,
|
||||
): Promise<{ correction: CorrectionResponse; item: { id: string; name: string; quantity: number } }> {
|
||||
const response = await apiClient.patch(
|
||||
`/stores/${storeId}/inventory/${itemId}/correct-quantity`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async correctSku(
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
data: CorrectSkuRequest,
|
||||
): Promise<{ correction: CorrectionResponse; item: { id: string; name: string; category?: string; barcode?: string } }> {
|
||||
const response = await apiClient.patch(
|
||||
`/stores/${storeId}/inventory/${itemId}/correct-sku`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async confirmItem(
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
): Promise<{ correction: CorrectionResponse; item: { id: string; name: string; quantity: number } }> {
|
||||
const response = await apiClient.post(
|
||||
`/stores/${storeId}/inventory/${itemId}/confirm`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getCorrectionHistory(
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
): Promise<{ corrections: CorrectionHistoryItem[] }> {
|
||||
const response = await apiClient.get(
|
||||
`/stores/${storeId}/inventory/${itemId}/history`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async submitProduct(data: SubmitProductRequest): Promise<{
|
||||
submission: { id: string; name: string; status: string; createdAt: string };
|
||||
}> {
|
||||
const response = await apiClient.post('/products/submit', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async searchProducts(query: string, limit = 10): Promise<{ products: ProductSearchResult[] }> {
|
||||
const response = await apiClient.get('/products/search', {
|
||||
params: { q: query, limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default feedbackService;
|
||||
@ -1,61 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
category?: string;
|
||||
barcode?: string;
|
||||
price?: number;
|
||||
imageUrl?: string;
|
||||
detectionConfidence?: number;
|
||||
isManuallyEdited?: boolean;
|
||||
lastDetectedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InventoryResponse {
|
||||
items: InventoryItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export const inventoryService = {
|
||||
getInventory: async (
|
||||
storeId: string,
|
||||
page = 1,
|
||||
limit = 50
|
||||
): Promise<InventoryResponse> => {
|
||||
const response = await apiClient.get<InventoryResponse>(
|
||||
`/stores/${storeId}/inventory`,
|
||||
{ params: { page, limit } }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getItem: async (storeId: string, itemId: string): Promise<InventoryItem> => {
|
||||
const response = await apiClient.get<InventoryItem>(
|
||||
`/stores/${storeId}/inventory/${itemId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateItem: async (
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
data: Partial<InventoryItem>
|
||||
): Promise<InventoryItem> => {
|
||||
const response = await apiClient.patch<InventoryItem>(
|
||||
`/stores/${storeId}/inventory/${itemId}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteItem: async (storeId: string, itemId: string): Promise<void> => {
|
||||
await apiClient.delete(`/stores/${storeId}/inventory/${itemId}`);
|
||||
},
|
||||
};
|
||||
@ -1,66 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export type NotificationType =
|
||||
| 'VIDEO_PROCESSING_COMPLETE'
|
||||
| 'VIDEO_PROCESSING_FAILED'
|
||||
| 'LOW_CREDITS'
|
||||
| 'PAYMENT_COMPLETE'
|
||||
| 'PAYMENT_FAILED'
|
||||
| 'REFERRAL_BONUS'
|
||||
| 'SYSTEM';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
isRead: boolean;
|
||||
isPushSent: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationsListResponse {
|
||||
notifications: Notification[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export const notificationsService = {
|
||||
getNotifications: async (page = 1, limit = 20): Promise<NotificationsListResponse> => {
|
||||
const response = await apiClient.get<NotificationsListResponse>('/notifications', {
|
||||
params: { page, limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUnreadCount: async (): Promise<{ count: number }> => {
|
||||
const response = await apiClient.get<{ count: number }>('/notifications/unread-count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsRead: async (notificationId: string): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.patch<{ success: boolean }>(
|
||||
`/notifications/${notificationId}/read`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAllAsRead: async (): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.post<{ success: boolean }>(
|
||||
'/notifications/mark-all-read'
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
registerFcmToken: async (token: string): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.post<{ success: boolean }>(
|
||||
'/notifications/register-token',
|
||||
{ token }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -1,76 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface CreditPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
credits: number;
|
||||
priceMXN: number;
|
||||
popular?: boolean;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
userId: string;
|
||||
packageId: string;
|
||||
amountMXN: number;
|
||||
creditsGranted: number;
|
||||
method: 'CARD' | 'OXXO' | '7ELEVEN';
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'REFUNDED';
|
||||
stripePaymentIntentId?: string;
|
||||
voucherCode?: string;
|
||||
voucherUrl?: string;
|
||||
expiresAt?: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreatePaymentRequest {
|
||||
packageId: string;
|
||||
method: 'card' | 'oxxo' | '7eleven';
|
||||
paymentMethodId?: string; // Required for card payments
|
||||
}
|
||||
|
||||
export interface PaymentResponse {
|
||||
paymentId: string;
|
||||
status: 'pending' | 'completed';
|
||||
method: string;
|
||||
// Card payment fields
|
||||
clientSecret?: string;
|
||||
// OXXO/7-Eleven fields
|
||||
voucherCode?: string;
|
||||
voucherUrl?: string;
|
||||
expiresAt?: string;
|
||||
amountMXN: number;
|
||||
}
|
||||
|
||||
export interface PaymentsListResponse {
|
||||
payments: Payment[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export const paymentsService = {
|
||||
getPackages: async (): Promise<CreditPackage[]> => {
|
||||
const response = await apiClient.get<CreditPackage[]>('/credits/packages');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createPayment: async (data: CreatePaymentRequest): Promise<PaymentResponse> => {
|
||||
const response = await apiClient.post<PaymentResponse>('/payments', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPaymentHistory: async (page = 1, limit = 20): Promise<PaymentsListResponse> => {
|
||||
const response = await apiClient.get<PaymentsListResponse>('/payments', {
|
||||
params: { page, limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPaymentById: async (paymentId: string): Promise<Payment> => {
|
||||
const response = await apiClient.get<Payment>(`/payments/${paymentId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -1,75 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ReferralStats {
|
||||
referralCode: string;
|
||||
totalReferrals: number;
|
||||
completedReferrals: number;
|
||||
pendingReferrals: number;
|
||||
totalCreditsEarned: number;
|
||||
}
|
||||
|
||||
export interface Referral {
|
||||
id: string;
|
||||
referrerId: string;
|
||||
referredId: string;
|
||||
referralCode: string;
|
||||
status: 'PENDING' | 'REGISTERED' | 'QUALIFIED' | 'REWARDED';
|
||||
referrerBonusCredits: number;
|
||||
referredBonusCredits: number;
|
||||
registeredAt?: string;
|
||||
qualifiedAt?: string;
|
||||
rewardedAt?: string;
|
||||
createdAt: string;
|
||||
referred?: {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReferralsListResponse {
|
||||
referrals: Referral[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface ValidateCodeResponse {
|
||||
valid: boolean;
|
||||
referrerName?: string;
|
||||
}
|
||||
|
||||
export const referralsService = {
|
||||
getMyCode: async (): Promise<{ referralCode: string }> => {
|
||||
const response = await apiClient.get<{ referralCode: string }>('/referrals/my-code');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStats: async (): Promise<ReferralStats> => {
|
||||
const response = await apiClient.get<ReferralStats>('/referrals/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getReferrals: async (page = 1, limit = 20): Promise<ReferralsListResponse> => {
|
||||
const response = await apiClient.get<ReferralsListResponse>('/referrals', {
|
||||
params: { page, limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
validateCode: async (code: string): Promise<ValidateCodeResponse> => {
|
||||
const response = await apiClient.get<ValidateCodeResponse>('/referrals/validate', {
|
||||
params: { code },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
applyCode: async (code: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post<{ success: boolean; message: string }>(
|
||||
'/referrals/apply',
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -1,171 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
// Report Types
|
||||
export interface ValuationSummary {
|
||||
totalItems: number;
|
||||
totalCost: number;
|
||||
totalPrice: number;
|
||||
potentialMargin: number;
|
||||
potentialMarginPercent: number;
|
||||
}
|
||||
|
||||
export interface ValuationByCategory {
|
||||
category: string;
|
||||
itemCount: number;
|
||||
totalCost: number;
|
||||
totalPrice: number;
|
||||
margin: number;
|
||||
}
|
||||
|
||||
export interface ValuationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
price: number;
|
||||
totalCost: number;
|
||||
totalPrice: number;
|
||||
margin: number;
|
||||
}
|
||||
|
||||
export interface ValuationReport {
|
||||
summary: ValuationSummary;
|
||||
byCategory: ValuationByCategory[];
|
||||
items: ValuationItem[];
|
||||
}
|
||||
|
||||
export interface MovementsSummary {
|
||||
period: { start: string; end: string };
|
||||
totalMovements: number;
|
||||
netChange: number;
|
||||
itemsIncreased: number;
|
||||
itemsDecreased: number;
|
||||
}
|
||||
|
||||
export interface MovementRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
type: string;
|
||||
change: number;
|
||||
quantityBefore: number;
|
||||
quantityAfter: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface MovementsByItem {
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
netChange: number;
|
||||
movementCount: number;
|
||||
}
|
||||
|
||||
export interface MovementsReport {
|
||||
summary: MovementsSummary;
|
||||
movements: MovementRecord[];
|
||||
byItem: MovementsByItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface CategorySummary {
|
||||
totalCategories: number;
|
||||
totalItems: number;
|
||||
totalValue: number;
|
||||
}
|
||||
|
||||
export interface CategoryDetail {
|
||||
name: string;
|
||||
itemCount: number;
|
||||
percentOfTotal: number;
|
||||
totalValue: number;
|
||||
lowStockCount: number;
|
||||
averagePrice: number;
|
||||
topItems: { name: string; quantity: number }[];
|
||||
}
|
||||
|
||||
export interface CategoriesReport {
|
||||
summary: CategorySummary;
|
||||
categories: CategoryDetail[];
|
||||
}
|
||||
|
||||
export interface LowStockSummary {
|
||||
totalAlerts: number;
|
||||
criticalCount: number;
|
||||
warningCount: number;
|
||||
totalValueAtRisk: number;
|
||||
}
|
||||
|
||||
export interface LowStockItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
minStock: number;
|
||||
shortage: number;
|
||||
estimatedReorderCost: number;
|
||||
lastMovementDate?: string;
|
||||
priority: 'critical' | 'warning' | 'watch';
|
||||
}
|
||||
|
||||
export interface LowStockReport {
|
||||
summary: LowStockSummary;
|
||||
items: LowStockItem[];
|
||||
}
|
||||
|
||||
export interface MovementsQueryParams {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export const reportsService = {
|
||||
/**
|
||||
* Get valuation report
|
||||
*/
|
||||
getValuationReport: async (storeId: string): Promise<ValuationReport> => {
|
||||
const response = await apiClient.get<ValuationReport>(
|
||||
`/stores/${storeId}/reports/valuation`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get movements report
|
||||
*/
|
||||
getMovementsReport: async (
|
||||
storeId: string,
|
||||
params?: MovementsQueryParams,
|
||||
): Promise<MovementsReport> => {
|
||||
const response = await apiClient.get<MovementsReport>(
|
||||
`/stores/${storeId}/reports/movements`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get categories report
|
||||
*/
|
||||
getCategoriesReport: async (storeId: string): Promise<CategoriesReport> => {
|
||||
const response = await apiClient.get<CategoriesReport>(
|
||||
`/stores/${storeId}/reports/categories`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get low stock report
|
||||
*/
|
||||
getLowStockReport: async (storeId: string): Promise<LowStockReport> => {
|
||||
const response = await apiClient.get<LowStockReport>(
|
||||
`/stores/${storeId}/reports/low-stock`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -1,70 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface Store {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zipCode?: string;
|
||||
giro?: string;
|
||||
ownerId: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateStoreRequest {
|
||||
name: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zipCode?: string;
|
||||
giro?: string;
|
||||
}
|
||||
|
||||
export interface UpdateStoreRequest {
|
||||
name?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zipCode?: string;
|
||||
giro?: string;
|
||||
}
|
||||
|
||||
export interface StoresListResponse {
|
||||
stores: Store[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export const storesService = {
|
||||
getStores: async (page = 1, limit = 20): Promise<StoresListResponse> => {
|
||||
const response = await apiClient.get<StoresListResponse>('/stores', {
|
||||
params: { page, limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStoreById: async (storeId: string): Promise<Store> => {
|
||||
const response = await apiClient.get<Store>(`/stores/${storeId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createStore: async (data: CreateStoreRequest): Promise<Store> => {
|
||||
const response = await apiClient.post<Store>('/stores', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateStore: async (storeId: string, data: UpdateStoreRequest): Promise<Store> => {
|
||||
const response = await apiClient.patch<Store>(`/stores/${storeId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteStore: async (storeId: string): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.delete<{ success: boolean }>(`/stores/${storeId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -1,35 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone: string;
|
||||
businessName?: string;
|
||||
location?: string;
|
||||
giro?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
name?: string;
|
||||
email?: string;
|
||||
businessName?: string;
|
||||
location?: string;
|
||||
giro?: string;
|
||||
}
|
||||
|
||||
export const usersService = {
|
||||
getProfile: async (): Promise<UserProfile> => {
|
||||
const response = await apiClient.get<UserProfile>('/users/me');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProfile: async (data: UpdateProfileRequest): Promise<UserProfile> => {
|
||||
const response = await apiClient.patch<UserProfile>('/users/me', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateFcmToken: async (fcmToken: string): Promise<void> => {
|
||||
await apiClient.patch('/users/me/fcm-token', { fcmToken });
|
||||
},
|
||||
};
|
||||
@ -1,70 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ValidationRequest {
|
||||
id: string;
|
||||
totalItems: number;
|
||||
itemsValidated: number;
|
||||
expiresAt: string;
|
||||
creditsRewarded: number;
|
||||
}
|
||||
|
||||
export interface ValidationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
category?: string;
|
||||
imageUrl?: string;
|
||||
detectionConfidence?: number;
|
||||
}
|
||||
|
||||
export interface ValidationItemResponse {
|
||||
inventoryItemId: string;
|
||||
isCorrect: boolean;
|
||||
correctedQuantity?: number;
|
||||
correctedName?: string;
|
||||
responseTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface SubmitValidationRequest {
|
||||
responses: ValidationItemResponse[];
|
||||
}
|
||||
|
||||
export interface SubmitValidationResponse {
|
||||
creditsRewarded: number;
|
||||
itemsValidated: number;
|
||||
}
|
||||
|
||||
const validationsService = {
|
||||
async check(videoId: string): Promise<{
|
||||
validationRequired: boolean;
|
||||
requestId?: string;
|
||||
}> {
|
||||
const response = await apiClient.get(`/validations/check/${videoId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getItems(requestId: string): Promise<{
|
||||
request: ValidationRequest;
|
||||
items: ValidationItem[];
|
||||
}> {
|
||||
const response = await apiClient.get(`/validations/${requestId}/items`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async submit(
|
||||
requestId: string,
|
||||
data: SubmitValidationRequest,
|
||||
): Promise<SubmitValidationResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/validations/${requestId}/submit`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async skip(requestId: string): Promise<void> {
|
||||
await apiClient.post(`/validations/${requestId}/skip`);
|
||||
},
|
||||
};
|
||||
|
||||
export default validationsService;
|
||||
@ -1,87 +0,0 @@
|
||||
import apiClient from './client';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
interface UploadResponse {
|
||||
videoId: string;
|
||||
uploadUrl: string;
|
||||
status: 'pending' | 'uploading' | 'processing' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
interface VideoStatus {
|
||||
id: string;
|
||||
status: 'pending' | 'uploading' | 'processing' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
resultItems?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface ProcessingResult {
|
||||
videoId: string;
|
||||
itemsDetected: number;
|
||||
items: Array<{
|
||||
name: string;
|
||||
quantity: number;
|
||||
confidence: number;
|
||||
category?: string;
|
||||
}>;
|
||||
creditsUsed: number;
|
||||
}
|
||||
|
||||
export const videosService = {
|
||||
initiateUpload: async (
|
||||
storeId: string,
|
||||
fileName: string,
|
||||
fileSize: number
|
||||
): Promise<UploadResponse> => {
|
||||
const response = await apiClient.post<UploadResponse>(
|
||||
`/stores/${storeId}/videos/initiate`,
|
||||
{ fileName, fileSize }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadVideo: async (
|
||||
uploadUrl: string,
|
||||
localUri: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<void> => {
|
||||
const uploadTask = FileSystem.createUploadTask(
|
||||
uploadUrl,
|
||||
localUri,
|
||||
{
|
||||
httpMethod: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'video/mp4',
|
||||
},
|
||||
},
|
||||
(progressEvent) => {
|
||||
const progress =
|
||||
progressEvent.totalBytesSent / progressEvent.totalBytesExpectedToSend;
|
||||
onProgress?.(progress);
|
||||
}
|
||||
);
|
||||
|
||||
await uploadTask.uploadAsync();
|
||||
},
|
||||
|
||||
confirmUpload: async (storeId: string, videoId: string): Promise<void> => {
|
||||
await apiClient.post(`/stores/${storeId}/videos/${videoId}/confirm`);
|
||||
},
|
||||
|
||||
getStatus: async (storeId: string, videoId: string): Promise<VideoStatus> => {
|
||||
const response = await apiClient.get<VideoStatus>(
|
||||
`/stores/${storeId}/videos/${videoId}/status`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getResult: async (
|
||||
storeId: string,
|
||||
videoId: string
|
||||
): Promise<ProcessingResult> => {
|
||||
const response = await apiClient.get<ProcessingResult>(
|
||||
`/stores/${storeId}/videos/${videoId}/result`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -1,198 +0,0 @@
|
||||
import { useAuthStore } from '../auth.store';
|
||||
import { authService } from '@services/api/auth.service';
|
||||
|
||||
// Mock the auth service
|
||||
jest.mock('@services/api/auth.service');
|
||||
|
||||
const mockAuthService = authService as jest.Mocked<typeof authService>;
|
||||
|
||||
describe('Auth Store', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should set user and tokens on successful login', async () => {
|
||||
const mockUser = { id: '1', phone: '+1234567890', name: 'Test User' };
|
||||
const mockResponse = {
|
||||
user: mockUser,
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
mockAuthService.login.mockResolvedValue(mockResponse);
|
||||
|
||||
await useAuthStore.getState().login('+1234567890', 'password123');
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user).toEqual(mockUser);
|
||||
expect(state.accessToken).toBe('access-token');
|
||||
expect(state.refreshToken).toBe('refresh-token');
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should set isLoading during login', async () => {
|
||||
mockAuthService.login.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
user: { id: '1', phone: '+1234567890', name: 'Test' },
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh',
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const loginPromise = useAuthStore.getState().login('+1234567890', 'pass');
|
||||
|
||||
// Check loading state during request
|
||||
expect(useAuthStore.getState().isLoading).toBe(true);
|
||||
|
||||
await loginPromise;
|
||||
|
||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset isLoading on login failure', async () => {
|
||||
mockAuthService.login.mockRejectedValue(new Error('Invalid credentials'));
|
||||
|
||||
await expect(
|
||||
useAuthStore.getState().login('+1234567890', 'wrong')
|
||||
).rejects.toThrow('Invalid credentials');
|
||||
|
||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initiateRegistration', () => {
|
||||
it('should call authService.initiateRegistration', async () => {
|
||||
mockAuthService.initiateRegistration.mockResolvedValue(undefined);
|
||||
|
||||
await useAuthStore.getState().initiateRegistration('+1234567890', 'Test');
|
||||
|
||||
expect(mockAuthService.initiateRegistration).toHaveBeenCalledWith({
|
||||
phone: '+1234567890',
|
||||
name: 'Test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyOtp', () => {
|
||||
it('should set user and tokens on successful verification', async () => {
|
||||
const mockUser = { id: '1', phone: '+1234567890', name: 'Test User' };
|
||||
mockAuthService.verifyOtp.mockResolvedValue({
|
||||
user: mockUser,
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
});
|
||||
|
||||
await useAuthStore.getState().verifyOtp('+1234567890', '123456', 'pass');
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user).toEqual(mockUser);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should clear all auth state', async () => {
|
||||
// Set initial authenticated state
|
||||
useAuthStore.setState({
|
||||
user: { id: '1', phone: '+1234567890', name: 'Test' },
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
mockAuthService.logout.mockResolvedValue(undefined);
|
||||
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.accessToken).toBeNull();
|
||||
expect(state.refreshToken).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should still clear state if logout API fails', async () => {
|
||||
useAuthStore.setState({
|
||||
user: { id: '1', phone: '+1234567890', name: 'Test' },
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
mockAuthService.logout.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshTokens', () => {
|
||||
it('should update tokens on successful refresh', async () => {
|
||||
useAuthStore.setState({
|
||||
refreshToken: 'old-refresh-token',
|
||||
accessToken: 'old-access-token',
|
||||
});
|
||||
|
||||
mockAuthService.refreshTokens.mockResolvedValue({
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
});
|
||||
|
||||
await useAuthStore.getState().refreshTokens();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.accessToken).toBe('new-access-token');
|
||||
expect(state.refreshToken).toBe('new-refresh-token');
|
||||
});
|
||||
|
||||
it('should logout on refresh failure', async () => {
|
||||
useAuthStore.setState({
|
||||
refreshToken: 'expired-token',
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
mockAuthService.refreshTokens.mockRejectedValue(new Error('Invalid token'));
|
||||
|
||||
await useAuthStore.getState().refreshTokens();
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should not call API if no refresh token', async () => {
|
||||
useAuthStore.setState({ refreshToken: null });
|
||||
|
||||
await useAuthStore.getState().refreshTokens();
|
||||
|
||||
expect(mockAuthService.refreshTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUser', () => {
|
||||
it('should update user', () => {
|
||||
const newUser = { id: '2', phone: '+9876543210', name: 'Updated User' };
|
||||
|
||||
useAuthStore.getState().setUser(newUser);
|
||||
|
||||
expect(useAuthStore.getState().user).toEqual(newUser);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,98 +0,0 @@
|
||||
import { useCreditsStore } from '../credits.store';
|
||||
import { creditsService } from '@services/api/credits.service';
|
||||
|
||||
jest.mock('@services/api/credits.service');
|
||||
|
||||
const mockCreditsService = creditsService as jest.Mocked<typeof creditsService>;
|
||||
|
||||
describe('Credits Store', () => {
|
||||
beforeEach(() => {
|
||||
useCreditsStore.setState({
|
||||
balance: 0,
|
||||
transactions: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchBalance', () => {
|
||||
it('should load current balance', async () => {
|
||||
mockCreditsService.getBalance.mockResolvedValue({ balance: 100 });
|
||||
|
||||
await useCreditsStore.getState().fetchBalance();
|
||||
|
||||
expect(useCreditsStore.getState().balance).toBe(100);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockCreditsService.getBalance.mockRejectedValue(new Error('Failed'));
|
||||
|
||||
await useCreditsStore.getState().fetchBalance();
|
||||
|
||||
expect(useCreditsStore.getState().error).toBe('Failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTransactions', () => {
|
||||
it('should load transaction history', async () => {
|
||||
const mockTransactions = [
|
||||
{ id: '1', type: 'PURCHASE', amount: 50, createdAt: new Date().toISOString() },
|
||||
{ id: '2', type: 'USAGE', amount: -10, createdAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
mockCreditsService.getTransactions.mockResolvedValue({
|
||||
transactions: mockTransactions,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
await useCreditsStore.getState().fetchTransactions();
|
||||
|
||||
expect(useCreditsStore.getState().transactions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purchaseCredits', () => {
|
||||
it('should update balance after purchase', async () => {
|
||||
useCreditsStore.setState({ balance: 50 });
|
||||
|
||||
mockCreditsService.purchaseCredits.mockResolvedValue({
|
||||
newBalance: 150,
|
||||
transaction: { id: '1', type: 'PURCHASE', amount: 100, createdAt: new Date().toISOString() },
|
||||
});
|
||||
|
||||
await useCreditsStore.getState().purchaseCredits('package-1', 'payment-method-1');
|
||||
|
||||
expect(useCreditsStore.getState().balance).toBe(150);
|
||||
});
|
||||
|
||||
it('should add transaction to history', async () => {
|
||||
const transaction = { id: '1', type: 'PURCHASE', amount: 100, createdAt: new Date().toISOString() };
|
||||
|
||||
mockCreditsService.purchaseCredits.mockResolvedValue({
|
||||
newBalance: 100,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await useCreditsStore.getState().purchaseCredits('package-1', 'payment-method-1');
|
||||
|
||||
const transactions = useCreditsStore.getState().transactions;
|
||||
expect(transactions[0]).toEqual(transaction);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumeCredits', () => {
|
||||
it('should decrease balance', async () => {
|
||||
useCreditsStore.setState({ balance: 100 });
|
||||
|
||||
mockCreditsService.consumeCredits.mockResolvedValue({
|
||||
newBalance: 90,
|
||||
transaction: { id: '1', type: 'USAGE', amount: -10, createdAt: new Date().toISOString() },
|
||||
});
|
||||
|
||||
await useCreditsStore.getState().consumeCredits(10, 'Video processing');
|
||||
|
||||
expect(useCreditsStore.getState().balance).toBe(90);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,162 +0,0 @@
|
||||
import { useFeedbackStore } from '../feedback.store';
|
||||
import { feedbackService } from '@services/api/feedback.service';
|
||||
|
||||
jest.mock('@services/api/feedback.service');
|
||||
|
||||
const mockFeedbackService = feedbackService as jest.Mocked<typeof feedbackService>;
|
||||
|
||||
describe('Feedback Store', () => {
|
||||
beforeEach(() => {
|
||||
useFeedbackStore.setState({
|
||||
corrections: [],
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
error: null,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchCorrections', () => {
|
||||
it('should load corrections history', async () => {
|
||||
const mockCorrections = [
|
||||
{ id: '1', itemId: 'item-1', type: 'QUANTITY', originalValue: 10, correctedValue: 15, createdAt: new Date().toISOString() },
|
||||
{ id: '2', itemId: 'item-2', type: 'SKU', originalValue: 'OLD123', correctedValue: 'NEW456', createdAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
mockFeedbackService.getCorrections.mockResolvedValue({ corrections: mockCorrections });
|
||||
|
||||
await useFeedbackStore.getState().fetchCorrections('store-1');
|
||||
|
||||
expect(useFeedbackStore.getState().corrections).toEqual(mockCorrections);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockFeedbackService.getCorrections.mockRejectedValue(new Error('Failed to load'));
|
||||
|
||||
await useFeedbackStore.getState().fetchCorrections('store-1');
|
||||
|
||||
expect(useFeedbackStore.getState().error).toBe('Failed to load');
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitQuantityCorrection', () => {
|
||||
it('should submit quantity correction', async () => {
|
||||
mockFeedbackService.submitCorrection.mockResolvedValue({
|
||||
id: '1',
|
||||
itemId: 'item-1',
|
||||
type: 'QUANTITY',
|
||||
originalValue: 10,
|
||||
correctedValue: 15,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = await useFeedbackStore.getState().submitQuantityCorrection(
|
||||
'store-1',
|
||||
'item-1',
|
||||
10,
|
||||
15,
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFeedbackService.submitCorrection).toHaveBeenCalledWith('store-1', {
|
||||
itemId: 'item-1',
|
||||
type: 'QUANTITY',
|
||||
originalValue: 10,
|
||||
correctedValue: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add correction to list', async () => {
|
||||
const newCorrection = {
|
||||
id: '1',
|
||||
itemId: 'item-1',
|
||||
type: 'QUANTITY',
|
||||
originalValue: 10,
|
||||
correctedValue: 15,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockFeedbackService.submitCorrection.mockResolvedValue(newCorrection);
|
||||
|
||||
await useFeedbackStore.getState().submitQuantityCorrection(
|
||||
'store-1',
|
||||
'item-1',
|
||||
10,
|
||||
15,
|
||||
);
|
||||
|
||||
expect(useFeedbackStore.getState().corrections).toContainEqual(newCorrection);
|
||||
});
|
||||
|
||||
it('should handle submission errors', async () => {
|
||||
mockFeedbackService.submitCorrection.mockRejectedValue(new Error('Submission failed'));
|
||||
|
||||
const result = await useFeedbackStore.getState().submitQuantityCorrection(
|
||||
'store-1',
|
||||
'item-1',
|
||||
10,
|
||||
15,
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(useFeedbackStore.getState().error).toBe('Submission failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitSkuCorrection', () => {
|
||||
it('should submit SKU correction', async () => {
|
||||
mockFeedbackService.submitCorrection.mockResolvedValue({
|
||||
id: '1',
|
||||
itemId: 'item-1',
|
||||
type: 'SKU',
|
||||
originalValue: 'OLD123',
|
||||
correctedValue: 'NEW456',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = await useFeedbackStore.getState().submitSkuCorrection(
|
||||
'store-1',
|
||||
'item-1',
|
||||
'OLD123',
|
||||
'NEW456',
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFeedbackService.submitCorrection).toHaveBeenCalledWith('store-1', {
|
||||
itemId: 'item-1',
|
||||
type: 'SKU',
|
||||
originalValue: 'OLD123',
|
||||
correctedValue: 'NEW456',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmItem', () => {
|
||||
it('should confirm item detection', async () => {
|
||||
mockFeedbackService.confirmItem.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await useFeedbackStore.getState().confirmItem('store-1', 'item-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFeedbackService.confirmItem).toHaveBeenCalledWith('store-1', 'item-1');
|
||||
});
|
||||
|
||||
it('should handle confirmation errors', async () => {
|
||||
mockFeedbackService.confirmItem.mockRejectedValue(new Error('Failed'));
|
||||
|
||||
const result = await useFeedbackStore.getState().confirmItem('store-1', 'item-1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear error state', () => {
|
||||
useFeedbackStore.setState({ error: 'Some error' });
|
||||
|
||||
useFeedbackStore.getState().clearError();
|
||||
|
||||
expect(useFeedbackStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,200 +0,0 @@
|
||||
import { useInventoryStore } from '../inventory.store';
|
||||
import { inventoryService } from '@services/api/inventory.service';
|
||||
|
||||
jest.mock('@services/api/inventory.service');
|
||||
|
||||
const mockInventoryService = inventoryService as jest.Mocked<
|
||||
typeof inventoryService
|
||||
>;
|
||||
|
||||
describe('Inventory Store', () => {
|
||||
beforeEach(() => {
|
||||
useInventoryStore.setState({
|
||||
items: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
currentPage: 1,
|
||||
hasMore: true,
|
||||
searchQuery: '',
|
||||
categoryFilter: null,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchItems', () => {
|
||||
it('should load inventory items', async () => {
|
||||
const mockItems = [
|
||||
{ id: '1', name: 'Item 1', quantity: 10, storeId: 'store-1' },
|
||||
{ id: '2', name: 'Item 2', quantity: 5, storeId: 'store-1' },
|
||||
];
|
||||
|
||||
mockInventoryService.getItems.mockResolvedValue({
|
||||
items: mockItems,
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await useInventoryStore.getState().fetchItems('store-1');
|
||||
|
||||
const state = useInventoryStore.getState();
|
||||
expect(state.items).toHaveLength(2);
|
||||
expect(state.hasMore).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
mockInventoryService.getItems.mockRejectedValue(
|
||||
new Error('Failed to fetch')
|
||||
);
|
||||
|
||||
await useInventoryStore.getState().fetchItems('store-1');
|
||||
|
||||
expect(useInventoryStore.getState().error).toBe('Failed to fetch');
|
||||
});
|
||||
|
||||
it('should set loading state during fetch', async () => {
|
||||
mockInventoryService.getItems.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
hasMore: false,
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const fetchPromise = useInventoryStore.getState().fetchItems('store-1');
|
||||
expect(useInventoryStore.getState().isLoading).toBe(true);
|
||||
|
||||
await fetchPromise;
|
||||
expect(useInventoryStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMore', () => {
|
||||
it('should load next page and append items', async () => {
|
||||
useInventoryStore.setState({
|
||||
items: [{ id: '1', name: 'Item 1', quantity: 10, storeId: 'store-1' }],
|
||||
currentPage: 1,
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
mockInventoryService.getItems.mockResolvedValue({
|
||||
items: [{ id: '2', name: 'Item 2', quantity: 5, storeId: 'store-1' }],
|
||||
total: 2,
|
||||
page: 2,
|
||||
limit: 50,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await useInventoryStore.getState().loadMore('store-1');
|
||||
|
||||
const state = useInventoryStore.getState();
|
||||
expect(state.items).toHaveLength(2);
|
||||
expect(state.currentPage).toBe(2);
|
||||
});
|
||||
|
||||
it('should not load if hasMore is false', async () => {
|
||||
useInventoryStore.setState({ hasMore: false });
|
||||
|
||||
await useInventoryStore.getState().loadMore('store-1');
|
||||
|
||||
expect(mockInventoryService.getItems).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateItem', () => {
|
||||
it('should update an item in the list', async () => {
|
||||
useInventoryStore.setState({
|
||||
items: [
|
||||
{ id: '1', name: 'Item 1', quantity: 10, storeId: 'store-1' },
|
||||
{ id: '2', name: 'Item 2', quantity: 5, storeId: 'store-1' },
|
||||
],
|
||||
});
|
||||
|
||||
const updatedItem = {
|
||||
id: '1',
|
||||
name: 'Updated Item',
|
||||
quantity: 20,
|
||||
storeId: 'store-1',
|
||||
};
|
||||
mockInventoryService.updateItem.mockResolvedValue(updatedItem);
|
||||
|
||||
await useInventoryStore
|
||||
.getState()
|
||||
.updateItem('store-1', '1', { name: 'Updated Item', quantity: 20 });
|
||||
|
||||
const items = useInventoryStore.getState().items;
|
||||
expect(items[0].name).toBe('Updated Item');
|
||||
expect(items[0].quantity).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteItem', () => {
|
||||
it('should remove item from the list', async () => {
|
||||
useInventoryStore.setState({
|
||||
items: [
|
||||
{ id: '1', name: 'Item 1', quantity: 10, storeId: 'store-1' },
|
||||
{ id: '2', name: 'Item 2', quantity: 5, storeId: 'store-1' },
|
||||
],
|
||||
});
|
||||
|
||||
mockInventoryService.deleteItem.mockResolvedValue(undefined);
|
||||
|
||||
await useInventoryStore.getState().deleteItem('store-1', '1');
|
||||
|
||||
const items = useInventoryStore.getState().items;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].id).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSearchQuery', () => {
|
||||
it('should update search query', () => {
|
||||
useInventoryStore.getState().setSearchQuery('test search');
|
||||
|
||||
expect(useInventoryStore.getState().searchQuery).toBe('test search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCategoryFilter', () => {
|
||||
it('should update category filter', () => {
|
||||
useInventoryStore.getState().setCategoryFilter('Electronics');
|
||||
|
||||
expect(useInventoryStore.getState().categoryFilter).toBe('Electronics');
|
||||
});
|
||||
|
||||
it('should allow null filter', () => {
|
||||
useInventoryStore.setState({ categoryFilter: 'Electronics' });
|
||||
useInventoryStore.getState().setCategoryFilter(null);
|
||||
|
||||
expect(useInventoryStore.getState().categoryFilter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearItems', () => {
|
||||
it('should reset items and pagination', () => {
|
||||
useInventoryStore.setState({
|
||||
items: [{ id: '1', name: 'Item', quantity: 10, storeId: 'store-1' }],
|
||||
currentPage: 5,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
useInventoryStore.getState().clearItems();
|
||||
|
||||
const state = useInventoryStore.getState();
|
||||
expect(state.items).toHaveLength(0);
|
||||
expect(state.currentPage).toBe(1);
|
||||
expect(state.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,100 +0,0 @@
|
||||
import { useNotificationsStore } from '../notifications.store';
|
||||
import { notificationsService } from '@services/api/notifications.service';
|
||||
|
||||
jest.mock('@services/api/notifications.service');
|
||||
|
||||
const mockNotificationsService = notificationsService as jest.Mocked<
|
||||
typeof notificationsService
|
||||
>;
|
||||
|
||||
describe('Notifications Store', () => {
|
||||
beforeEach(() => {
|
||||
useNotificationsStore.setState({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchNotifications', () => {
|
||||
it('should load notifications', async () => {
|
||||
const mockNotifications = [
|
||||
{ id: '1', title: 'Notification 1', read: false, createdAt: new Date().toISOString() },
|
||||
{ id: '2', title: 'Notification 2', read: true, createdAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
mockNotificationsService.getNotifications.mockResolvedValue({
|
||||
notifications: mockNotifications,
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
await useNotificationsStore.getState().fetchNotifications();
|
||||
|
||||
const state = useNotificationsStore.getState();
|
||||
expect(state.notifications).toHaveLength(2);
|
||||
expect(state.unreadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsRead', () => {
|
||||
it('should mark notification as read', async () => {
|
||||
useNotificationsStore.setState({
|
||||
notifications: [
|
||||
{ id: '1', title: 'Notification 1', read: false, createdAt: new Date().toISOString() },
|
||||
],
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
mockNotificationsService.markAsRead.mockResolvedValue(undefined);
|
||||
|
||||
await useNotificationsStore.getState().markAsRead('1');
|
||||
|
||||
const state = useNotificationsStore.getState();
|
||||
expect(state.notifications[0].read).toBe(true);
|
||||
expect(state.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllAsRead', () => {
|
||||
it('should mark all notifications as read', async () => {
|
||||
useNotificationsStore.setState({
|
||||
notifications: [
|
||||
{ id: '1', title: 'N1', read: false, createdAt: new Date().toISOString() },
|
||||
{ id: '2', title: 'N2', read: false, createdAt: new Date().toISOString() },
|
||||
],
|
||||
unreadCount: 2,
|
||||
});
|
||||
|
||||
mockNotificationsService.markAllAsRead.mockResolvedValue(undefined);
|
||||
|
||||
await useNotificationsStore.getState().markAllAsRead();
|
||||
|
||||
const state = useNotificationsStore.getState();
|
||||
expect(state.notifications.every((n) => n.read)).toBe(true);
|
||||
expect(state.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNotification', () => {
|
||||
it('should remove notification from list', async () => {
|
||||
useNotificationsStore.setState({
|
||||
notifications: [
|
||||
{ id: '1', title: 'N1', read: false, createdAt: new Date().toISOString() },
|
||||
{ id: '2', title: 'N2', read: true, createdAt: new Date().toISOString() },
|
||||
],
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
mockNotificationsService.deleteNotification.mockResolvedValue(undefined);
|
||||
|
||||
await useNotificationsStore.getState().deleteNotification('1');
|
||||
|
||||
const state = useNotificationsStore.getState();
|
||||
expect(state.notifications).toHaveLength(1);
|
||||
expect(state.notifications[0].id).toBe('2');
|
||||
expect(state.unreadCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,152 +0,0 @@
|
||||
import { usePaymentsStore } from '../payments.store';
|
||||
import { paymentsService } from '@services/api/payments.service';
|
||||
|
||||
jest.mock('@services/api/payments.service');
|
||||
|
||||
const mockPaymentsService = paymentsService as jest.Mocked<typeof paymentsService>;
|
||||
|
||||
describe('Payments Store', () => {
|
||||
beforeEach(() => {
|
||||
usePaymentsStore.setState({
|
||||
packages: [],
|
||||
payments: [],
|
||||
currentPayment: null,
|
||||
total: 0,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isProcessing: false,
|
||||
error: null,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchPackages', () => {
|
||||
it('should load available packages', async () => {
|
||||
const mockPackages = [
|
||||
{ id: '1', name: 'Basic', credits: 100, price: 9.99 },
|
||||
{ id: '2', name: 'Pro', credits: 500, price: 39.99 },
|
||||
];
|
||||
|
||||
mockPaymentsService.getPackages.mockResolvedValue(mockPackages);
|
||||
|
||||
await usePaymentsStore.getState().fetchPackages();
|
||||
|
||||
expect(usePaymentsStore.getState().packages).toEqual(mockPackages);
|
||||
expect(usePaymentsStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockPaymentsService.getPackages.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await usePaymentsStore.getState().fetchPackages();
|
||||
|
||||
expect(usePaymentsStore.getState().error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPayments', () => {
|
||||
it('should load payment history', async () => {
|
||||
const mockPayments = [
|
||||
{ id: '1', amount: 9.99, status: 'COMPLETED', createdAt: new Date().toISOString() },
|
||||
{ id: '2', amount: 39.99, status: 'COMPLETED', createdAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
mockPaymentsService.getPaymentHistory.mockResolvedValue({
|
||||
payments: mockPayments,
|
||||
total: 2,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await usePaymentsStore.getState().fetchPayments(true);
|
||||
|
||||
expect(usePaymentsStore.getState().payments).toEqual(mockPayments);
|
||||
expect(usePaymentsStore.getState().total).toBe(2);
|
||||
});
|
||||
|
||||
it('should append payments when not refreshing', async () => {
|
||||
usePaymentsStore.setState({
|
||||
payments: [{ id: '1', amount: 9.99, status: 'COMPLETED', createdAt: new Date().toISOString() }],
|
||||
page: 2,
|
||||
});
|
||||
|
||||
mockPaymentsService.getPaymentHistory.mockResolvedValue({
|
||||
payments: [{ id: '2', amount: 39.99, status: 'COMPLETED', createdAt: new Date().toISOString() }],
|
||||
total: 2,
|
||||
page: 2,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await usePaymentsStore.getState().fetchPayments(false);
|
||||
|
||||
expect(usePaymentsStore.getState().payments).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should create payment and store response', async () => {
|
||||
const mockResponse = {
|
||||
paymentId: 'payment-1',
|
||||
checkoutUrl: 'https://checkout.example.com',
|
||||
status: 'PENDING',
|
||||
};
|
||||
|
||||
mockPaymentsService.createPayment.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await usePaymentsStore.getState().createPayment({
|
||||
packageId: 'package-1',
|
||||
paymentMethod: 'card',
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(usePaymentsStore.getState().currentPayment).toEqual(mockResponse);
|
||||
expect(usePaymentsStore.getState().isProcessing).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle payment errors', async () => {
|
||||
mockPaymentsService.createPayment.mockRejectedValue(new Error('Payment failed'));
|
||||
|
||||
const result = await usePaymentsStore.getState().createPayment({
|
||||
packageId: 'package-1',
|
||||
paymentMethod: 'card',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(usePaymentsStore.getState().error).toBe('Payment failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPaymentById', () => {
|
||||
it('should fetch payment by ID', async () => {
|
||||
const mockPayment = { id: '1', amount: 9.99, status: 'COMPLETED', createdAt: new Date().toISOString() };
|
||||
mockPaymentsService.getPaymentById.mockResolvedValue(mockPayment);
|
||||
|
||||
const result = await usePaymentsStore.getState().getPaymentById('1');
|
||||
|
||||
expect(result).toEqual(mockPayment);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCurrentPayment', () => {
|
||||
it('should clear current payment', () => {
|
||||
usePaymentsStore.setState({
|
||||
currentPayment: { paymentId: '1', checkoutUrl: 'url', status: 'PENDING' },
|
||||
});
|
||||
|
||||
usePaymentsStore.getState().clearCurrentPayment();
|
||||
|
||||
expect(usePaymentsStore.getState().currentPayment).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearError', () => {
|
||||
it('should clear error state', () => {
|
||||
usePaymentsStore.setState({ error: 'Some error' });
|
||||
|
||||
usePaymentsStore.getState().clearError();
|
||||
|
||||
expect(usePaymentsStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,95 +0,0 @@
|
||||
import { useReferralsStore } from '../referrals.store';
|
||||
import { referralsService } from '@services/api/referrals.service';
|
||||
|
||||
jest.mock('@services/api/referrals.service');
|
||||
|
||||
const mockReferralsService = referralsService as jest.Mocked<typeof referralsService>;
|
||||
|
||||
describe('Referrals Store', () => {
|
||||
beforeEach(() => {
|
||||
useReferralsStore.setState({
|
||||
referralCode: null,
|
||||
referrals: [],
|
||||
stats: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchReferralCode', () => {
|
||||
it('should load referral code', async () => {
|
||||
mockReferralsService.getReferralCode.mockResolvedValue({
|
||||
code: 'REF123',
|
||||
shareUrl: 'https://app.example.com/r/REF123',
|
||||
});
|
||||
|
||||
await useReferralsStore.getState().fetchReferralCode();
|
||||
|
||||
expect(useReferralsStore.getState().referralCode).toBe('REF123');
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockReferralsService.getReferralCode.mockRejectedValue(new Error('Failed'));
|
||||
|
||||
await useReferralsStore.getState().fetchReferralCode();
|
||||
|
||||
expect(useReferralsStore.getState().error).toBe('Failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchReferrals', () => {
|
||||
it('should load referral list', async () => {
|
||||
const mockReferrals = [
|
||||
{ id: '1', referredUserId: 'user-1', status: 'COMPLETED', creditsEarned: 50, createdAt: new Date().toISOString() },
|
||||
{ id: '2', referredUserId: 'user-2', status: 'PENDING', creditsEarned: 0, createdAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
mockReferralsService.getReferrals.mockResolvedValue({ referrals: mockReferrals });
|
||||
|
||||
await useReferralsStore.getState().fetchReferrals();
|
||||
|
||||
expect(useReferralsStore.getState().referrals).toEqual(mockReferrals);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchStats', () => {
|
||||
it('should load referral statistics', async () => {
|
||||
const mockStats = {
|
||||
totalReferrals: 10,
|
||||
completedReferrals: 8,
|
||||
pendingReferrals: 2,
|
||||
totalCreditsEarned: 400,
|
||||
};
|
||||
|
||||
mockReferralsService.getReferralStats.mockResolvedValue(mockStats);
|
||||
|
||||
await useReferralsStore.getState().fetchStats();
|
||||
|
||||
expect(useReferralsStore.getState().stats).toEqual(mockStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyReferralCode', () => {
|
||||
it('should apply referral code successfully', async () => {
|
||||
mockReferralsService.applyReferralCode.mockResolvedValue({
|
||||
success: true,
|
||||
creditsAwarded: 25,
|
||||
});
|
||||
|
||||
const result = await useReferralsStore.getState().applyReferralCode('FRIEND123');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockReferralsService.applyReferralCode).toHaveBeenCalledWith('FRIEND123');
|
||||
});
|
||||
|
||||
it('should handle invalid referral code', async () => {
|
||||
mockReferralsService.applyReferralCode.mockRejectedValue(new Error('Invalid code'));
|
||||
|
||||
const result = await useReferralsStore.getState().applyReferralCode('INVALID');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(useReferralsStore.getState().error).toBe('Invalid code');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,149 +0,0 @@
|
||||
import { useStoresStore } from '../stores.store';
|
||||
import { storesService } from '@services/api/stores.service';
|
||||
|
||||
jest.mock('@services/api/stores.service');
|
||||
|
||||
const mockStoresService = storesService as jest.Mocked<typeof storesService>;
|
||||
|
||||
describe('Stores Store', () => {
|
||||
beforeEach(() => {
|
||||
useStoresStore.setState({
|
||||
stores: [],
|
||||
currentStore: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchStores', () => {
|
||||
it('should load all stores', async () => {
|
||||
const mockStores = [
|
||||
{ id: '1', name: 'Store 1', ownerId: 'user-1' },
|
||||
{ id: '2', name: 'Store 2', ownerId: 'user-1' },
|
||||
];
|
||||
|
||||
mockStoresService.getStores.mockResolvedValue(mockStores);
|
||||
|
||||
await useStoresStore.getState().fetchStores();
|
||||
|
||||
expect(useStoresStore.getState().stores).toEqual(mockStores);
|
||||
});
|
||||
|
||||
it('should set first store as current if none selected', async () => {
|
||||
const mockStores = [
|
||||
{ id: '1', name: 'Store 1', ownerId: 'user-1' },
|
||||
{ id: '2', name: 'Store 2', ownerId: 'user-1' },
|
||||
];
|
||||
|
||||
mockStoresService.getStores.mockResolvedValue(mockStores);
|
||||
|
||||
await useStoresStore.getState().fetchStores();
|
||||
|
||||
expect(useStoresStore.getState().currentStore).toEqual(mockStores[0]);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockStoresService.getStores.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await useStoresStore.getState().fetchStores();
|
||||
|
||||
expect(useStoresStore.getState().error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStore', () => {
|
||||
it('should add new store to list', async () => {
|
||||
const newStore = { id: '3', name: 'New Store', ownerId: 'user-1' };
|
||||
mockStoresService.createStore.mockResolvedValue(newStore);
|
||||
|
||||
await useStoresStore.getState().createStore({ name: 'New Store' });
|
||||
|
||||
const stores = useStoresStore.getState().stores;
|
||||
expect(stores).toContainEqual(newStore);
|
||||
});
|
||||
|
||||
it('should set new store as current', async () => {
|
||||
const newStore = { id: '3', name: 'New Store', ownerId: 'user-1' };
|
||||
mockStoresService.createStore.mockResolvedValue(newStore);
|
||||
|
||||
await useStoresStore.getState().createStore({ name: 'New Store' });
|
||||
|
||||
expect(useStoresStore.getState().currentStore).toEqual(newStore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStore', () => {
|
||||
it('should update store in list', async () => {
|
||||
useStoresStore.setState({
|
||||
stores: [{ id: '1', name: 'Store 1', ownerId: 'user-1' }],
|
||||
});
|
||||
|
||||
const updatedStore = { id: '1', name: 'Updated Store', ownerId: 'user-1' };
|
||||
mockStoresService.updateStore.mockResolvedValue(updatedStore);
|
||||
|
||||
await useStoresStore.getState().updateStore('1', { name: 'Updated Store' });
|
||||
|
||||
expect(useStoresStore.getState().stores[0].name).toBe('Updated Store');
|
||||
});
|
||||
|
||||
it('should update currentStore if it was updated', async () => {
|
||||
const currentStore = { id: '1', name: 'Store 1', ownerId: 'user-1' };
|
||||
useStoresStore.setState({
|
||||
stores: [currentStore],
|
||||
currentStore,
|
||||
});
|
||||
|
||||
const updatedStore = { id: '1', name: 'Updated Store', ownerId: 'user-1' };
|
||||
mockStoresService.updateStore.mockResolvedValue(updatedStore);
|
||||
|
||||
await useStoresStore.getState().updateStore('1', { name: 'Updated Store' });
|
||||
|
||||
expect(useStoresStore.getState().currentStore?.name).toBe('Updated Store');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteStore', () => {
|
||||
it('should remove store from list', async () => {
|
||||
useStoresStore.setState({
|
||||
stores: [
|
||||
{ id: '1', name: 'Store 1', ownerId: 'user-1' },
|
||||
{ id: '2', name: 'Store 2', ownerId: 'user-1' },
|
||||
],
|
||||
});
|
||||
|
||||
mockStoresService.deleteStore.mockResolvedValue(undefined);
|
||||
|
||||
await useStoresStore.getState().deleteStore('1');
|
||||
|
||||
const stores = useStoresStore.getState().stores;
|
||||
expect(stores).toHaveLength(1);
|
||||
expect(stores[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should clear currentStore if deleted', async () => {
|
||||
const storeToDelete = { id: '1', name: 'Store 1', ownerId: 'user-1' };
|
||||
useStoresStore.setState({
|
||||
stores: [storeToDelete],
|
||||
currentStore: storeToDelete,
|
||||
});
|
||||
|
||||
mockStoresService.deleteStore.mockResolvedValue(undefined);
|
||||
|
||||
await useStoresStore.getState().deleteStore('1');
|
||||
|
||||
expect(useStoresStore.getState().currentStore).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCurrentStore', () => {
|
||||
it('should set current store', () => {
|
||||
const store = { id: '1', name: 'Store 1', ownerId: 'user-1' };
|
||||
useStoresStore.setState({ stores: [store] });
|
||||
|
||||
useStoresStore.getState().setCurrentStore(store);
|
||||
|
||||
expect(useStoresStore.getState().currentStore).toEqual(store);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,146 +0,0 @@
|
||||
import { useValidationsStore } from '../validations.store';
|
||||
import { validationsService } from '@services/api/validations.service';
|
||||
|
||||
jest.mock('@services/api/validations.service');
|
||||
|
||||
const mockValidationsService = validationsService as jest.Mocked<typeof validationsService>;
|
||||
|
||||
describe('Validations Store', () => {
|
||||
beforeEach(() => {
|
||||
useValidationsStore.setState({
|
||||
currentValidation: null,
|
||||
pendingItems: [],
|
||||
validatedItems: [],
|
||||
progress: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('startValidation', () => {
|
||||
it('should start a new validation session', async () => {
|
||||
const mockValidation = {
|
||||
id: 'validation-1',
|
||||
storeId: 'store-1',
|
||||
videoId: 'video-1',
|
||||
status: 'IN_PROGRESS',
|
||||
totalItems: 10,
|
||||
validatedItems: 0,
|
||||
};
|
||||
|
||||
mockValidationsService.startValidation.mockResolvedValue(mockValidation);
|
||||
|
||||
await useValidationsStore.getState().startValidation('store-1', 'video-1');
|
||||
|
||||
expect(useValidationsStore.getState().currentValidation).toEqual(mockValidation);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockValidationsService.startValidation.mockRejectedValue(new Error('Failed to start'));
|
||||
|
||||
await useValidationsStore.getState().startValidation('store-1', 'video-1');
|
||||
|
||||
expect(useValidationsStore.getState().error).toBe('Failed to start');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPendingItems', () => {
|
||||
it('should load pending items for validation', async () => {
|
||||
const mockItems = [
|
||||
{ id: '1', name: 'Item 1', quantity: 10, status: 'PENDING' },
|
||||
{ id: '2', name: 'Item 2', quantity: 5, status: 'PENDING' },
|
||||
];
|
||||
|
||||
mockValidationsService.getPendingItems.mockResolvedValue({ items: mockItems });
|
||||
|
||||
await useValidationsStore.getState().fetchPendingItems('validation-1');
|
||||
|
||||
expect(useValidationsStore.getState().pendingItems).toEqual(mockItems);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateItem', () => {
|
||||
it('should validate an item as correct', async () => {
|
||||
useValidationsStore.setState({
|
||||
pendingItems: [
|
||||
{ id: '1', name: 'Item 1', quantity: 10, status: 'PENDING' },
|
||||
{ id: '2', name: 'Item 2', quantity: 5, status: 'PENDING' },
|
||||
],
|
||||
validatedItems: [],
|
||||
progress: 0,
|
||||
});
|
||||
|
||||
mockValidationsService.validateItem.mockResolvedValue({
|
||||
success: true,
|
||||
item: { id: '1', name: 'Item 1', quantity: 10, status: 'VALIDATED' },
|
||||
});
|
||||
|
||||
await useValidationsStore.getState().validateItem('validation-1', '1', true);
|
||||
|
||||
const state = useValidationsStore.getState();
|
||||
expect(state.pendingItems).toHaveLength(1);
|
||||
expect(state.validatedItems).toHaveLength(1);
|
||||
expect(state.progress).toBe(50);
|
||||
});
|
||||
|
||||
it('should validate an item with correction', async () => {
|
||||
useValidationsStore.setState({
|
||||
pendingItems: [{ id: '1', name: 'Item 1', quantity: 10, status: 'PENDING' }],
|
||||
validatedItems: [],
|
||||
});
|
||||
|
||||
mockValidationsService.validateItem.mockResolvedValue({
|
||||
success: true,
|
||||
item: { id: '1', name: 'Item 1', quantity: 15, status: 'CORRECTED' },
|
||||
});
|
||||
|
||||
await useValidationsStore.getState().validateItem('validation-1', '1', false, 15);
|
||||
|
||||
expect(mockValidationsService.validateItem).toHaveBeenCalledWith(
|
||||
'validation-1',
|
||||
'1',
|
||||
false,
|
||||
15,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeValidation', () => {
|
||||
it('should complete the validation session', async () => {
|
||||
useValidationsStore.setState({
|
||||
currentValidation: { id: 'validation-1', status: 'IN_PROGRESS', totalItems: 10, validatedItems: 10 },
|
||||
});
|
||||
|
||||
mockValidationsService.completeValidation.mockResolvedValue({
|
||||
id: 'validation-1',
|
||||
status: 'COMPLETED',
|
||||
totalItems: 10,
|
||||
validatedItems: 10,
|
||||
});
|
||||
|
||||
await useValidationsStore.getState().completeValidation('validation-1');
|
||||
|
||||
expect(useValidationsStore.getState().currentValidation?.status).toBe('COMPLETED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearValidation', () => {
|
||||
it('should reset all validation state', () => {
|
||||
useValidationsStore.setState({
|
||||
currentValidation: { id: '1', status: 'IN_PROGRESS', totalItems: 10, validatedItems: 5 },
|
||||
pendingItems: [{ id: '1', name: 'Item', quantity: 10, status: 'PENDING' }],
|
||||
validatedItems: [{ id: '2', name: 'Item 2', quantity: 5, status: 'VALIDATED' }],
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
useValidationsStore.getState().clearValidation();
|
||||
|
||||
const state = useValidationsStore.getState();
|
||||
expect(state.currentValidation).toBeNull();
|
||||
expect(state.pendingItems).toHaveLength(0);
|
||||
expect(state.validatedItems).toHaveLength(0);
|
||||
expect(state.progress).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,137 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { authService } from '@services/api/auth.service';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions
|
||||
login: (phone: string, password: string) => Promise<void>;
|
||||
initiateRegistration: (phone: string, name: string) => Promise<void>;
|
||||
verifyOtp: (phone: string, otp: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshTokens: () => Promise<void>;
|
||||
setUser: (user: User) => void;
|
||||
}
|
||||
|
||||
const secureStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
return await SecureStore.getItemAsync(name);
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
await SecureStore.setItemAsync(name, value);
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await SecureStore.deleteItemAsync(name);
|
||||
},
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
|
||||
login: async (phone: string, password: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const response = await authService.login({ phone, password });
|
||||
set({
|
||||
user: response.user,
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
initiateRegistration: async (phone: string, name: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
await authService.initiateRegistration({ phone, name });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
verifyOtp: async (phone: string, otp: string, password: string) => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const response = await authService.verifyOtp({ phone, otp, password });
|
||||
set({
|
||||
user: response.user,
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const { refreshToken } = get();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await authService.logout(refreshToken);
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
}
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
|
||||
refreshTokens: async () => {
|
||||
const { refreshToken } = get();
|
||||
if (!refreshToken) return;
|
||||
|
||||
try {
|
||||
const response = await authService.refreshTokens(refreshToken);
|
||||
set({
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
});
|
||||
} catch {
|
||||
// If refresh fails, logout
|
||||
get().logout();
|
||||
}
|
||||
},
|
||||
|
||||
setUser: (user: User) => {
|
||||
set({ user });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
storage: createJSONStorage(() => secureStorage),
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -1,138 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { creditsService } from '@services/api/credits.service';
|
||||
|
||||
interface CreditBalance {
|
||||
balance: number;
|
||||
totalPurchased: number;
|
||||
totalConsumed: number;
|
||||
totalFromReferrals: number;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CreditsState {
|
||||
balance: CreditBalance | null;
|
||||
transactions: Transaction[];
|
||||
transactionsTotal: number;
|
||||
transactionsPage: number;
|
||||
transactionsHasMore: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastFetched: number | null;
|
||||
|
||||
// Actions
|
||||
fetchBalance: () => Promise<void>;
|
||||
fetchTransactions: (refresh?: boolean) => Promise<void>;
|
||||
deductCredits: (amount: number) => void;
|
||||
addCredits: (amount: number) => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const MAX_CACHED_TRANSACTIONS = 50;
|
||||
|
||||
export const useCreditsStore = create<CreditsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
balance: null,
|
||||
transactions: [],
|
||||
transactionsTotal: 0,
|
||||
transactionsPage: 1,
|
||||
transactionsHasMore: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
|
||||
fetchBalance: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await creditsService.getBalance();
|
||||
set({
|
||||
balance: {
|
||||
balance: response.balance,
|
||||
totalPurchased: response.totalPurchased || 0,
|
||||
totalConsumed: response.totalConsumed || 0,
|
||||
totalFromReferrals: response.totalFromReferrals || 0,
|
||||
},
|
||||
isLoading: false,
|
||||
lastFetched: Date.now(),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al cargar balance';
|
||||
set({ error: message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchTransactions: async (refresh = false) => {
|
||||
const state = get();
|
||||
if (state.isLoading && !refresh) return;
|
||||
|
||||
const page = refresh ? 1 : state.transactionsPage;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await creditsService.getTransactions(page, 20);
|
||||
const newTransactions = refresh
|
||||
? response.transactions
|
||||
: [...state.transactions, ...response.transactions];
|
||||
|
||||
set({
|
||||
transactions: newTransactions.slice(0, MAX_CACHED_TRANSACTIONS),
|
||||
transactionsTotal: response.total,
|
||||
transactionsPage: page + 1,
|
||||
transactionsHasMore: page * 20 < response.total,
|
||||
isLoading: false,
|
||||
lastFetched: Date.now(),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al cargar transacciones';
|
||||
set({ error: message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deductCredits: (amount: number) => {
|
||||
set((state) => ({
|
||||
balance: state.balance
|
||||
? {
|
||||
...state.balance,
|
||||
balance: Math.max(0, state.balance.balance - amount),
|
||||
totalConsumed: state.balance.totalConsumed + amount,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
|
||||
addCredits: (amount: number) => {
|
||||
set((state) => ({
|
||||
balance: state.balance
|
||||
? {
|
||||
...state.balance,
|
||||
balance: state.balance.balance + amount,
|
||||
totalPurchased: state.balance.totalPurchased + amount,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}),
|
||||
{
|
||||
name: 'miinventario-credits',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
balance: state.balance,
|
||||
transactions: state.transactions.slice(0, MAX_CACHED_TRANSACTIONS),
|
||||
transactionsTotal: state.transactionsTotal,
|
||||
lastFetched: state.lastFetched,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -1,129 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import feedbackService, {
|
||||
CorrectionHistoryItem,
|
||||
CorrectQuantityRequest,
|
||||
CorrectSkuRequest,
|
||||
SubmitProductRequest,
|
||||
ProductSearchResult,
|
||||
} from '../services/api/feedback.service';
|
||||
|
||||
interface FeedbackState {
|
||||
correctionHistory: CorrectionHistoryItem[];
|
||||
searchResults: ProductSearchResult[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
correctQuantity: (
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
data: CorrectQuantityRequest,
|
||||
) => Promise<void>;
|
||||
correctSku: (
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
data: CorrectSkuRequest,
|
||||
) => Promise<void>;
|
||||
confirmItem: (storeId: string, itemId: string) => Promise<void>;
|
||||
fetchCorrectionHistory: (storeId: string, itemId: string) => Promise<void>;
|
||||
submitProduct: (data: SubmitProductRequest) => Promise<void>;
|
||||
searchProducts: (query: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useFeedbackStore = create<FeedbackState>((set, get) => ({
|
||||
correctionHistory: [],
|
||||
searchResults: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
correctQuantity: async (storeId, itemId, data) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await feedbackService.correctQuantity(storeId, itemId, data);
|
||||
// Refresh history after correction
|
||||
await get().fetchCorrectionHistory(storeId, itemId);
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error al corregir cantidad' });
|
||||
throw error;
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
correctSku: async (storeId, itemId, data) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await feedbackService.correctSku(storeId, itemId, data);
|
||||
await get().fetchCorrectionHistory(storeId, itemId);
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error al corregir nombre' });
|
||||
throw error;
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
confirmItem: async (storeId, itemId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await feedbackService.confirmItem(storeId, itemId);
|
||||
await get().fetchCorrectionHistory(storeId, itemId);
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error al confirmar item' });
|
||||
throw error;
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchCorrectionHistory: async (storeId, itemId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { corrections } = await feedbackService.getCorrectionHistory(
|
||||
storeId,
|
||||
itemId,
|
||||
);
|
||||
set({ correctionHistory: corrections });
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error al cargar historial' });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
submitProduct: async (data) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await feedbackService.submitProduct(data);
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error al enviar producto' });
|
||||
throw error;
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
searchProducts: async (query) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { products } = await feedbackService.searchProducts(query);
|
||||
set({ searchResults: products });
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error en busqueda' });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
correctionHistory: [],
|
||||
searchResults: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
@ -1,141 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { inventoryService, InventoryItem } from '@services/api/inventory.service';
|
||||
|
||||
interface InventoryState {
|
||||
items: InventoryItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedStoreId: string | null;
|
||||
searchQuery: string;
|
||||
selectedCategory: string | null;
|
||||
lastFetched: number | null;
|
||||
|
||||
// Actions
|
||||
fetchItems: (storeId: string, refresh?: boolean) => Promise<void>;
|
||||
fetchInventory: (storeId: string) => Promise<void>;
|
||||
updateItem: (itemId: string, data: Partial<InventoryItem>) => Promise<void>;
|
||||
deleteItem: (itemId: string) => Promise<void>;
|
||||
setSelectedStore: (storeId: string) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSelectedCategory: (category: string | null) => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const MAX_CACHED_ITEMS = 100;
|
||||
|
||||
export const useInventoryStore = create<InventoryState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedStoreId: null,
|
||||
searchQuery: '',
|
||||
selectedCategory: null,
|
||||
lastFetched: null,
|
||||
|
||||
fetchItems: async (storeId: string, refresh = false) => {
|
||||
const state = get();
|
||||
if (state.isLoading && !refresh) return;
|
||||
|
||||
const page = refresh ? 1 : state.page;
|
||||
|
||||
set({ isLoading: true, error: null, selectedStoreId: storeId });
|
||||
|
||||
try {
|
||||
const response = await inventoryService.getInventory(storeId, page, 50);
|
||||
const newItems = refresh ? response.items : [...state.items, ...response.items];
|
||||
|
||||
set({
|
||||
items: newItems.slice(0, MAX_CACHED_ITEMS),
|
||||
total: response.total,
|
||||
page: page + 1,
|
||||
hasMore: response.hasMore,
|
||||
isLoading: false,
|
||||
lastFetched: Date.now(),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al cargar inventario';
|
||||
set({ error: message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchInventory: async (storeId: string) => {
|
||||
return get().fetchItems(storeId, true);
|
||||
},
|
||||
|
||||
updateItem: async (itemId: string, data: Partial<InventoryItem>) => {
|
||||
const { selectedStoreId } = get();
|
||||
if (!selectedStoreId) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
await inventoryService.updateItem(selectedStoreId, itemId, data);
|
||||
set((state) => ({
|
||||
items: state.items.map((item) =>
|
||||
item.id === itemId ? { ...item, ...data, isManuallyEdited: true } : item
|
||||
),
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al actualizar producto';
|
||||
set({ error: message, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteItem: async (itemId: string) => {
|
||||
const { selectedStoreId } = get();
|
||||
if (!selectedStoreId) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
await inventoryService.deleteItem(selectedStoreId, itemId);
|
||||
set((state) => ({
|
||||
items: state.items.filter((item) => item.id !== itemId),
|
||||
total: state.total - 1,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al eliminar producto';
|
||||
set({ error: message, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedStore: (storeId: string) => {
|
||||
set({ selectedStoreId: storeId });
|
||||
},
|
||||
|
||||
setSearchQuery: (query: string) => {
|
||||
set({ searchQuery: query });
|
||||
},
|
||||
|
||||
setSelectedCategory: (category: string | null) => {
|
||||
set({ selectedCategory: category });
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}),
|
||||
{
|
||||
name: 'miinventario-inventory',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
items: state.items.slice(0, MAX_CACHED_ITEMS),
|
||||
total: state.total,
|
||||
selectedStoreId: state.selectedStoreId,
|
||||
lastFetched: state.lastFetched,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -1,129 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
notificationsService,
|
||||
Notification,
|
||||
} from '@services/api/notifications.service';
|
||||
|
||||
interface NotificationsState {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
total: number;
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastFetched: number | null;
|
||||
// Actions
|
||||
fetchNotifications: (refresh?: boolean) => Promise<void>;
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
markAsRead: (notificationId: string) => Promise<void>;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
registerFcmToken: (token: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const MAX_CACHED_NOTIFICATIONS = 50;
|
||||
|
||||
export const useNotificationsStore = create<NotificationsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
total: 0,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
|
||||
fetchNotifications: async (refresh = false) => {
|
||||
const state = get();
|
||||
if (state.isLoading) return;
|
||||
|
||||
const page = refresh ? 1 : state.page;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await notificationsService.getNotifications(page, 20);
|
||||
const newNotifications = refresh
|
||||
? response.notifications
|
||||
: [...state.notifications, ...response.notifications];
|
||||
|
||||
set({
|
||||
notifications: newNotifications.slice(0, MAX_CACHED_NOTIFICATIONS),
|
||||
total: response.total,
|
||||
page: response.page + 1,
|
||||
hasMore: response.hasMore,
|
||||
isLoading: false,
|
||||
lastFetched: Date.now(),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al cargar notificaciones';
|
||||
set({ error: message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
try {
|
||||
const response = await notificationsService.getUnreadCount();
|
||||
set({ unreadCount: response.count });
|
||||
} catch {
|
||||
// Silently fail for badge count
|
||||
}
|
||||
},
|
||||
|
||||
markAsRead: async (notificationId: string) => {
|
||||
try {
|
||||
await notificationsService.markAsRead(notificationId);
|
||||
const state = get();
|
||||
set({
|
||||
notifications: state.notifications.map((n) =>
|
||||
n.id === notificationId ? { ...n, isRead: true } : n
|
||||
),
|
||||
unreadCount: Math.max(0, state.unreadCount - 1),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al marcar notificacion';
|
||||
set({ error: message });
|
||||
}
|
||||
},
|
||||
|
||||
markAllAsRead: async () => {
|
||||
try {
|
||||
await notificationsService.markAllAsRead();
|
||||
const state = get();
|
||||
set({
|
||||
notifications: state.notifications.map((n) => ({ ...n, isRead: true })),
|
||||
unreadCount: 0,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al marcar notificaciones';
|
||||
set({ error: message });
|
||||
}
|
||||
},
|
||||
|
||||
registerFcmToken: async (token: string) => {
|
||||
try {
|
||||
await notificationsService.registerFcmToken(token);
|
||||
} catch {
|
||||
// Silently fail for token registration
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}),
|
||||
{
|
||||
name: 'miinventario-notifications',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
notifications: state.notifications.slice(0, MAX_CACHED_NOTIFICATIONS),
|
||||
unreadCount: state.unreadCount,
|
||||
total: state.total,
|
||||
lastFetched: state.lastFetched,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -1,105 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
paymentsService,
|
||||
CreditPackage,
|
||||
Payment,
|
||||
CreatePaymentRequest,
|
||||
PaymentResponse,
|
||||
} from '@services/api/payments.service';
|
||||
|
||||
interface PaymentsState {
|
||||
packages: CreditPackage[];
|
||||
payments: Payment[];
|
||||
currentPayment: PaymentResponse | null;
|
||||
total: number;
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
isProcessing: boolean;
|
||||
error: string | null;
|
||||
// Actions
|
||||
fetchPackages: () => Promise<void>;
|
||||
fetchPayments: (refresh?: boolean) => Promise<void>;
|
||||
createPayment: (data: CreatePaymentRequest) => Promise<PaymentResponse | null>;
|
||||
getPaymentById: (paymentId: string) => Promise<Payment | null>;
|
||||
clearCurrentPayment: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const usePaymentsStore = create<PaymentsState>((set, get) => ({
|
||||
packages: [],
|
||||
payments: [],
|
||||
currentPayment: null,
|
||||
total: 0,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isProcessing: false,
|
||||
error: null,
|
||||
|
||||
fetchPackages: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const packages = await paymentsService.getPackages();
|
||||
set({ packages, isLoading: false });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al cargar paquetes';
|
||||
set({ error: message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchPayments: async (refresh = false) => {
|
||||
const state = get();
|
||||
if (state.isLoading) return;
|
||||
|
||||
const page = refresh ? 1 : state.page;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await paymentsService.getPaymentHistory(page, 20);
|
||||
set({
|
||||
payments: refresh
|
||||
? response.payments
|
||||
: [...state.payments, ...response.payments],
|
||||
total: response.total,
|
||||
page: response.page + 1,
|
||||
hasMore: response.hasMore,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al cargar pagos';
|
||||
set({ error: message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createPayment: async (data: CreatePaymentRequest) => {
|
||||
set({ isProcessing: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await paymentsService.createPayment(data);
|
||||
set({ currentPayment: response, isProcessing: false });
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al procesar pago';
|
||||
set({ error: message, isProcessing: false });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getPaymentById: async (paymentId: string) => {
|
||||
try {
|
||||
const payment = await paymentsService.getPaymentById(paymentId);
|
||||
return payment;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al obtener pago';
|
||||
set({ error: message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
clearCurrentPayment: () => set({ currentPayment: null }),
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
@ -1,101 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
referralsService,
|
||||
ReferralStats,
|
||||
Referral,
|
||||
} from '@services/api/referrals.service';
|
||||
|
||||
interface ReferralsState {
|
||||
stats: ReferralStats | null;
|
||||
referrals: Referral[];
|
||||
total: number;
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
isValidating: boolean;
|
||||
error: string | null;
|
||||
// Actions
|
||||
fetchStats: () => Promise<void>;
|
||||
fetchReferrals: (refresh?: boolean) => Promise<void>;
|
||||
validateCode: (code: string) => Promise<{ valid: boolean; referrerName?: string }>;
|
||||
applyCode: (code: string) => Promise<{ success: boolean; message: string }>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useReferralsStore = create<ReferralsState>((set, get) => ({
|
||||
stats: null,
|
||||
referrals: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isValidating: false,
|
||||
error: null,
|
||||
|
||||
fetchStats: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const stats = await referralsService.getStats();
|
||||
set({ stats, isLoading: false });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al cargar estadisticas';
|
||||
set({ error: message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchReferrals: async (refresh = false) => {
|
||||
const state = get();
|
||||
if (state.isLoading) return;
|
||||
|
||||
const page = refresh ? 1 : state.page;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await referralsService.getReferrals(page, 20);
|
||||
set({
|
||||
referrals: refresh
|
||||
? response.referrals
|
||||
: [...state.referrals, ...response.referrals],
|
||||
total: response.total,
|
||||
page: response.page + 1,
|
||||
hasMore: response.hasMore,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al cargar referidos';
|
||||
set({ error: message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
validateCode: async (code: string) => {
|
||||
set({ isValidating: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await referralsService.validateCode(code);
|
||||
set({ isValidating: false });
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al validar codigo';
|
||||
set({ error: message, isValidating: false });
|
||||
return { valid: false };
|
||||
}
|
||||
},
|
||||
|
||||
applyCode: async (code: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await referralsService.applyCode(code);
|
||||
set({ isLoading: false });
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al aplicar codigo';
|
||||
set({ error: message, isLoading: false });
|
||||
return { success: false, message };
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
@ -1,164 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
storesService,
|
||||
Store,
|
||||
CreateStoreRequest,
|
||||
UpdateStoreRequest,
|
||||
} from '@services/api/stores.service';
|
||||
|
||||
interface StoresState {
|
||||
stores: Store[];
|
||||
currentStore: Store | null;
|
||||
total: number;
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastFetched: number | null;
|
||||
// Actions
|
||||
fetchStores: (refresh?: boolean) => Promise<void>;
|
||||
selectStore: (store: Store) => void;
|
||||
getStoreById: (storeId: string) => Promise<Store | null>;
|
||||
createStore: (data: CreateStoreRequest) => Promise<Store | null>;
|
||||
updateStore: (storeId: string, data: UpdateStoreRequest) => Promise<Store | null>;
|
||||
deleteStore: (storeId: string) => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useStoresStore = create<StoresState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
stores: [],
|
||||
currentStore: null,
|
||||
total: 0,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
|
||||
fetchStores: async (refresh = false) => {
|
||||
const state = get();
|
||||
if (state.isLoading) return;
|
||||
|
||||
const page = refresh ? 1 : state.page;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await storesService.getStores(page, 20);
|
||||
const newStores = refresh
|
||||
? response.stores
|
||||
: [...state.stores, ...response.stores];
|
||||
|
||||
set({
|
||||
stores: newStores,
|
||||
total: response.total,
|
||||
page: response.page + 1,
|
||||
hasMore: response.hasMore,
|
||||
isLoading: false,
|
||||
lastFetched: Date.now(),
|
||||
// Auto-select first store if none selected
|
||||
currentStore: state.currentStore || newStores[0] || null,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al cargar tiendas';
|
||||
set({ error: message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectStore: (store: Store) => {
|
||||
set({ currentStore: store });
|
||||
},
|
||||
|
||||
getStoreById: async (storeId: string) => {
|
||||
try {
|
||||
const store = await storesService.getStoreById(storeId);
|
||||
return store;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al obtener tienda';
|
||||
set({ error: message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
createStore: async (data: CreateStoreRequest) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const store = await storesService.createStore(data);
|
||||
const state = get();
|
||||
set({
|
||||
stores: [store, ...state.stores],
|
||||
currentStore: store,
|
||||
isLoading: false,
|
||||
});
|
||||
return store;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al crear tienda';
|
||||
set({ error: message, isLoading: false });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
updateStore: async (storeId: string, data: UpdateStoreRequest) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const updatedStore = await storesService.updateStore(storeId, data);
|
||||
const state = get();
|
||||
set({
|
||||
stores: state.stores.map((s) =>
|
||||
s.id === storeId ? updatedStore : s
|
||||
),
|
||||
currentStore:
|
||||
state.currentStore?.id === storeId ? updatedStore : state.currentStore,
|
||||
isLoading: false,
|
||||
});
|
||||
return updatedStore;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al actualizar tienda';
|
||||
set({ error: message, isLoading: false });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteStore: async (storeId: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
await storesService.deleteStore(storeId);
|
||||
const state = get();
|
||||
const newStores = state.stores.filter((s) => s.id !== storeId);
|
||||
set({
|
||||
stores: newStores,
|
||||
currentStore:
|
||||
state.currentStore?.id === storeId
|
||||
? newStores[0] || null
|
||||
: state.currentStore,
|
||||
isLoading: false,
|
||||
});
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al eliminar tienda';
|
||||
set({ error: message, isLoading: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}),
|
||||
{
|
||||
name: 'miinventario-stores',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
stores: state.stores,
|
||||
currentStore: state.currentStore,
|
||||
total: state.total,
|
||||
lastFetched: state.lastFetched,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -1,158 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import validationsService, {
|
||||
ValidationRequest,
|
||||
ValidationItem,
|
||||
ValidationItemResponse,
|
||||
} from '../services/api/validations.service';
|
||||
|
||||
interface ValidationsState {
|
||||
pendingRequest: ValidationRequest | null;
|
||||
items: ValidationItem[];
|
||||
responses: ValidationItemResponse[];
|
||||
currentItemIndex: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
creditsRewarded: number | null;
|
||||
|
||||
// Actions
|
||||
checkForValidation: (videoId: string) => Promise<boolean>;
|
||||
fetchValidationItems: (requestId: string) => Promise<void>;
|
||||
addResponse: (response: ValidationItemResponse) => void;
|
||||
nextItem: () => void;
|
||||
previousItem: () => void;
|
||||
submitValidation: () => Promise<void>;
|
||||
skipValidation: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useValidationsStore = create<ValidationsState>((set, get) => ({
|
||||
pendingRequest: null,
|
||||
items: [],
|
||||
responses: [],
|
||||
currentItemIndex: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
creditsRewarded: null,
|
||||
|
||||
checkForValidation: async (videoId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const result = await validationsService.check(videoId);
|
||||
if (result.validationRequired && result.requestId) {
|
||||
await get().fetchValidationItems(result.requestId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error al verificar validacion' });
|
||||
return false;
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchValidationItems: async (requestId) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { request, items } = await validationsService.getItems(requestId);
|
||||
set({
|
||||
pendingRequest: request,
|
||||
items,
|
||||
responses: [],
|
||||
currentItemIndex: 0,
|
||||
});
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error al cargar items' });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addResponse: (response) => {
|
||||
const { responses } = get();
|
||||
const existingIndex = responses.findIndex(
|
||||
(r) => r.inventoryItemId === response.inventoryItemId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const updated = [...responses];
|
||||
updated[existingIndex] = response;
|
||||
set({ responses: updated });
|
||||
} else {
|
||||
set({ responses: [...responses, response] });
|
||||
}
|
||||
},
|
||||
|
||||
nextItem: () => {
|
||||
const { currentItemIndex, items } = get();
|
||||
if (currentItemIndex < items.length - 1) {
|
||||
set({ currentItemIndex: currentItemIndex + 1 });
|
||||
}
|
||||
},
|
||||
|
||||
previousItem: () => {
|
||||
const { currentItemIndex } = get();
|
||||
if (currentItemIndex > 0) {
|
||||
set({ currentItemIndex: currentItemIndex - 1 });
|
||||
}
|
||||
},
|
||||
|
||||
submitValidation: async () => {
|
||||
const { pendingRequest, responses } = get();
|
||||
if (!pendingRequest) {
|
||||
set({ error: 'No hay validacion pendiente' });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const result = await validationsService.submit(pendingRequest.id, {
|
||||
responses,
|
||||
});
|
||||
set({
|
||||
creditsRewarded: result.creditsRewarded,
|
||||
pendingRequest: null,
|
||||
items: [],
|
||||
responses: [],
|
||||
currentItemIndex: 0,
|
||||
});
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error al enviar validacion' });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
skipValidation: async () => {
|
||||
const { pendingRequest } = get();
|
||||
if (!pendingRequest) return;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await validationsService.skip(pendingRequest.id);
|
||||
set({
|
||||
pendingRequest: null,
|
||||
items: [],
|
||||
responses: [],
|
||||
currentItemIndex: 0,
|
||||
});
|
||||
} catch (error: any) {
|
||||
set({ error: error.response?.data?.message || 'Error al omitir validacion' });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
pendingRequest: null,
|
||||
items: [],
|
||||
responses: [],
|
||||
currentItemIndex: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
creditsRewarded: null,
|
||||
}),
|
||||
}));
|
||||
@ -1,76 +0,0 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
primaryLight: string;
|
||||
background: string;
|
||||
card: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
border: string;
|
||||
error: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
colors: ThemeColors;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const lightColors: ThemeColors = {
|
||||
primary: '#2563eb',
|
||||
primaryLight: '#f0f9ff',
|
||||
background: '#f5f5f5',
|
||||
card: '#ffffff',
|
||||
text: '#1a1a1a',
|
||||
textSecondary: '#666666',
|
||||
border: '#e5e5e5',
|
||||
error: '#ef4444',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
};
|
||||
|
||||
const darkColors: ThemeColors = {
|
||||
primary: '#3b82f6',
|
||||
primaryLight: '#1e3a5f',
|
||||
background: '#0f0f0f',
|
||||
card: '#1a1a1a',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#a3a3a3',
|
||||
border: '#2d2d2d',
|
||||
error: '#f87171',
|
||||
success: '#4ade80',
|
||||
warning: '#fbbf24',
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<Theme>({
|
||||
colors: lightColors,
|
||||
isDark: false,
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
|
||||
const theme = useMemo<Theme>(() => ({
|
||||
colors: isDark ? darkColors : lightColors,
|
||||
isDark,
|
||||
}), [isDark]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={theme}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): Theme {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export function useColors(): ThemeColors {
|
||||
const { colors } = useTheme();
|
||||
return colors;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user