[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