Initial commit: Estructura backend y frontend con estándar VPS

- Backend migrado a estructura VPS (src/ subfolder)
- Frontend con estructura Vite + React 19 + Tailwind
- Configuración PostgreSQL con Pool
- API service con interceptores JWT
- Ambos servidores funcionando (backend:3001, frontend:5173)
This commit is contained in:
2025-12-09 00:35:46 -03:00
commit 2a88b4a71b
106 changed files with 22508 additions and 0 deletions

1
administracion/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3001/api

View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3001/api

24
administracion/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
administracion/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
administracion/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>administracion</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4186
administracion/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
{
"name": "administracion",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"jsbarcode": "^3.12.1",
"lucide-react": "^0.556.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.10.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.1",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,66 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import ProtectedRoute from './components/ProtectedRoute';
import AdminLayout from './components/AdminLayout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Products from './pages/Products';
import Categories from './pages/Categories';
import Inventory from './pages/Inventory';
import Orders from './pages/Orders';
import Shipments from './pages/Shipments';
import Channels from './pages/Channels';
import Settings from './pages/Settings';
function App() {
return (
<BrowserRouter>
<Toaster
position="top-right"
toastOptions={{
duration: 3000,
style: {
background: '#363636',
color: '#fff',
},
success: {
iconTheme: {
primary: '#10b981',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="products" element={<Products />} />
<Route path="categories" element={<Categories />} />
<Route path="inventory" element={<Inventory />} />
<Route path="orders" element={<Orders />} />
<Route path="shipments" element={<Shipments />} />
<Route path="channels" element={<Channels />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,113 @@
import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom';
import {
LayoutDashboard, Package, FolderTree, Warehouse,
ShoppingCart, TruckIcon, Share2, Settings, LogOut, Menu, X
} from 'lucide-react';
import { useState } from 'react';
import { useAuthStore } from '../store/authStore';
export default function AdminLayout() {
const [sidebarOpen, setSidebarOpen] = useState(true);
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const menuItems = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' },
{ icon: Package, label: 'Productos', path: '/products' },
{ icon: FolderTree, label: 'Categorías', path: '/categories' },
{ icon: Warehouse, label: 'Inventario', path: '/inventory' },
{ icon: ShoppingCart, label: 'Órdenes', path: '/orders' },
{ icon: TruckIcon, label: 'Envíos', path: '/shipments' },
{ icon: Share2, label: 'Canales', path: '/channels' },
{ icon: Settings, label: 'Configuración', path: '/settings' },
];
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="flex h-screen bg-gray-100">
{/* Sidebar */}
<aside className={`bg-gray-900 text-white transition-all duration-300 ${sidebarOpen ? 'w-64' : 'w-20'}`}>
<div className="flex items-center justify-between p-4 border-b border-gray-800">
{sidebarOpen && <h1 className="text-xl font-bold">OfertaWeb</h1>}
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2 hover:bg-gray-800 rounded">
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
<nav className="mt-4">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-4 py-3 hover:bg-gray-800 transition-colors ${
isActive ? 'bg-gray-800 border-l-4 border-blue-500' : ''
}`}
title={!sidebarOpen ? item.label : ''}
>
<Icon size={20} />
{sidebarOpen && <span>{item.label}</span>}
</Link>
);
})}
</nav>
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center font-bold">
{user?.nombre?.charAt(0)}
</div>
{sidebarOpen && (
<div className="flex-1">
<p className="text-sm font-medium">{user?.nombre}</p>
<p className="text-xs text-gray-400">{user?.role}</p>
</div>
)}
<button
onClick={handleLogout}
className="p-2 hover:bg-gray-800 rounded"
title="Cerrar sesión"
>
<LogOut size={18} />
</button>
</div>
</div>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="bg-white shadow-sm p-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold text-gray-800">
{menuItems.find(item => item.path === location.pathname)?.label || 'Panel de Administración'}
</h2>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{new Date().toLocaleDateString('es-CL', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</span>
</div>
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 p-6">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { X } from 'lucide-react';
import toast from 'react-hot-toast';
import { categoryService } from '../services';
export default function CategoryModal({ category, categories, onClose }) {
const { register, handleSubmit, formState: { errors }, reset } = useForm();
const isEditing = !!category;
useEffect(() => {
if (category) {
reset(category);
}
}, [category, reset]);
const onSubmit = async (data) => {
try {
if (isEditing) {
await categoryService.update(category.id, data);
toast.success('Categoría actualizada');
} else {
await categoryService.create(data);
toast.success('Categoría creada');
}
onClose();
} catch (error) {
toast.error(error.response?.data?.error || 'Error al guardar categoría');
}
};
// Filtrar categorías para no permitir seleccionar como padre a sí misma o sus hijos
const availableParents = categories.filter(cat => {
if (!isEditing) return true;
return cat.id !== category.id;
});
// Aplanar el árbol de categorías para el select
const flattenCategories = (cats, level = 0) => {
let result = [];
cats.forEach(cat => {
result.push({ ...cat, level });
if (cat.children && cat.children.length > 0) {
result = result.concat(flattenCategories(cat.children, level + 1));
}
});
return result;
};
const flatCategories = flattenCategories(availableParents);
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-white rounded-lg shadow-xl max-w-lg w-full"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-2xl font-bold text-gray-800">
{isEditing ? 'Editar Categoría' : 'Nueva Categoría'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
{/* Nombre */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre <span className="text-red-500">*</span>
</label>
<input
type="text"
{...register('nombre', { required: 'Nombre es requerido' })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ej: Electrónica"
/>
{errors.nombre && (
<p className="mt-1 text-sm text-red-600">{errors.nombre.message}</p>
)}
</div>
{/* Código */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Código para SKU
</label>
<input
type="text"
{...register('codigo', {
maxLength: { value: 10, message: 'Máximo 10 caracteres' },
pattern: { value: /^[A-Z0-9]*$/, message: 'Solo mayúsculas y números' }
})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent uppercase"
placeholder="ELEC"
maxLength={10}
/>
{errors.codigo && (
<p className="mt-1 text-sm text-red-600">{errors.codigo.message}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Se usará para generar SKUs automáticos (ej: ELEC-001). Se auto-genera si está vacío.
</p>
</div>
{/* Descripción */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Descripción
</label>
<textarea
{...register('descripcion')}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Descripción de la categoría"
/>
</div>
{/* Categoría Padre */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Categoría Padre
</label>
<select
{...register('parent_id')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Sin categoría padre (raíz)</option>
{flatCategories.map((cat) => (
<option key={cat.id} value={cat.id}>
{'—'.repeat(cat.level)} {cat.nombre}
</option>
))}
</select>
</div>
{/* Estado */}
<div className="flex items-center">
<input
type="checkbox"
{...register('is_active')}
defaultChecked={isEditing ? category.is_active : true}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label className="ml-2 block text-sm text-gray-700">
Categoría activa
</label>
</div>
{/* Buttons */}
<div className="flex justify-end gap-4 pt-4 border-t">
<button
type="button"
onClick={onClose}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{isEditing ? 'Actualizar' : 'Crear Categoría'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { useState } from 'react';
import { Upload, X, Image as ImageIcon, Star } from 'lucide-react';
import axios from 'axios';
import toast from 'react-hot-toast';
const API_URL = 'http://localhost:3001/api';
export default function ImageUploader({ productId, images, onImagesChange }) {
const [uploading, setUploading] = useState(false);
const handleFileSelect = async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
setUploading(true);
try {
for (const file of files) {
const formData = new FormData();
formData.append('image', file);
formData.append('alt_text', file.name);
formData.append('is_primary', images.length === 0); // Primera imagen es principal
const response = await axios.post(
`${API_URL}/upload/product/${productId}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
}
);
onImagesChange([...images, response.data.image]);
}
toast.success(`${files.length} imagen(es) subida(s)`);
e.target.value = ''; // Reset input
} catch (error) {
console.error('Error subiendo imágenes:', error);
toast.error('Error al subir imágenes');
} finally {
setUploading(false);
}
};
const handleDelete = async (imageId) => {
if (!confirm('¿Eliminar esta imagen?')) return;
try {
await axios.delete(`${API_URL}/upload/image/${imageId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
onImagesChange(images.filter(img => img.id !== imageId));
toast.success('Imagen eliminada');
} catch (error) {
console.error('Error eliminando imagen:', error);
toast.error('Error al eliminar imagen');
}
};
const handleSetPrimary = async (imageId) => {
try {
// Actualizar localmente
const updatedImages = images.map(img => ({
...img,
is_primary: img.id === imageId
}));
onImagesChange(updatedImages);
// Aquí podrías hacer un PATCH al backend si implementas ese endpoint
toast.success('Imagen principal actualizada');
} catch (error) {
console.error('Error:', error);
toast.error('Error al actualizar');
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">
Imágenes del Producto
</label>
<label className="cursor-pointer">
<input
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
className="hidden"
disabled={uploading}
/>
<div className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
<Upload size={16} />
{uploading ? 'Subiendo...' : 'Subir Imágenes'}
</div>
</label>
</div>
{/* Grid de imágenes */}
<div className="grid grid-cols-4 gap-4">
{images.map((image) => (
<div
key={image.id}
className="relative group aspect-square bg-gray-100 rounded-lg overflow-hidden border-2 border-gray-200 hover:border-blue-500 transition-colors"
>
<img
src={`http://localhost:3001${image.url}`}
alt={image.alt_text}
className="w-full h-full object-cover"
/>
{/* Overlay con acciones */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-all flex items-center justify-center gap-2">
<button
type="button"
onClick={() => handleSetPrimary(image.id)}
className={`p-2 rounded-full transition-all ${
image.is_primary
? 'bg-yellow-500 text-white'
: 'bg-white text-gray-700 opacity-0 group-hover:opacity-100'
}`}
title={image.is_primary ? 'Imagen principal' : 'Marcar como principal'}
>
<Star size={16} fill={image.is_primary ? 'currentColor' : 'none'} />
</button>
<button
type="button"
onClick={() => handleDelete(image.id)}
className="p-2 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 hover:bg-red-600 transition-all"
title="Eliminar"
>
<X size={16} />
</button>
</div>
{/* Badge de imagen principal */}
{image.is_primary && (
<div className="absolute top-2 left-2 bg-yellow-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
<Star size={12} fill="currentColor" />
Principal
</div>
)}
</div>
))}
{/* Placeholder para subir */}
{images.length === 0 && (
<label className="cursor-pointer aspect-square bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center hover:border-blue-500 hover:bg-blue-50 transition-colors">
<input
type="file"
multiple
accept="image/*"
onChange={handleFileSelect}
className="hidden"
disabled={uploading}
/>
<ImageIcon size={32} className="text-gray-400 mb-2" />
<span className="text-sm text-gray-500">Subir imágenes</span>
</label>
)}
</div>
<p className="text-xs text-gray-500">
Puedes subir múltiples imágenes. La primera será la principal. Máximo 5MB por imagen.
</p>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { Printer } from 'lucide-react';
import JsBarcode from 'jsbarcode';
import { useEffect, useRef } from 'react';
export default function PrintLabel({ product, onClose }) {
const barcodeRef = useRef(null);
useEffect(() => {
if (barcodeRef.current && product.sku) {
try {
JsBarcode(barcodeRef.current, product.sku, {
format: 'CODE128',
width: 2,
height: 60,
displayValue: true,
fontSize: 14,
margin: 5
});
} catch (err) {
console.error('Error generando código de barras:', err);
}
}
}, [product.sku]);
const handlePrint = () => {
window.print();
};
return (
<>
{/* Vista para pantalla */}
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 print:hidden">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Vista previa de etiqueta</h2>
<div className="border-2 border-dashed border-gray-300 p-4 mb-4 bg-white">
<LabelContent product={product} barcodeRef={barcodeRef} />
</div>
<div className="flex gap-3">
<button
onClick={handlePrint}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Printer size={20} />
Imprimir
</button>
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
</div>
<p className="text-xs text-gray-500 mt-3">
Selecciona tu impresora de etiquetas en el diálogo de impresión
</p>
</div>
</div>
{/* Vista para impresión */}
<div className="hidden print:block">
<LabelContent product={product} barcodeRef={barcodeRef} />
</div>
{/* Estilos de impresión */}
<style>{`
@media print {
@page {
size: 10cm 5cm;
margin: 0;
}
body * {
visibility: hidden;
}
.print\\:block, .print\\:block * {
visibility: visible;
}
.print\\:block {
position: absolute;
left: 0;
top: 0;
width: 10cm;
height: 5cm;
}
}
`}</style>
</>
);
}
function LabelContent({ product, barcodeRef }) {
return (
<div className="text-center space-y-2">
<h3 className="font-bold text-lg leading-tight line-clamp-2">
{product.nombre}
</h3>
<div className="flex justify-center">
<svg ref={barcodeRef}></svg>
</div>
<div className="flex justify-between items-center text-sm">
<span className="font-mono text-xs">{product.sku}</span>
<span className="font-bold text-xl">
${parseInt(product.precio_base).toLocaleString('es-CL')}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,310 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { X, RefreshCw } from 'lucide-react';
import toast from 'react-hot-toast';
import { productService } from '../services';
import ImageUploader from './ImageUploader';
export default function ProductModal({ product, categories, onClose }) {
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useForm();
const isEditing = !!product;
const [skuCounter, setSkuCounter] = useState(1);
const [images, setImages] = useState([]);
const categoryId = watch('category_id');
useEffect(() => {
if (product) {
reset(product);
setImages(product.images || []);
}
}, [product, reset]);
// Auto-generar SKU cuando cambia la categoría
useEffect(() => {
if (!isEditing && categoryId) {
generateSKU(categoryId);
}
}, [categoryId, isEditing]);
const generateSKU = (catId) => {
const category = categories?.find(c => c.id === parseInt(catId));
if (category) {
const prefix = category.codigo || category.nombre.substring(0, 4).toUpperCase();
const newSKU = `${prefix}-${String(skuCounter).padStart(3, '0')}`;
setValue('sku', newSKU);
setSkuCounter(prev => prev + 1);
}
};
const handleRegenerateSKU = () => {
const catId = watch('category_id');
if (catId) {
generateSKU(catId);
} else {
const newSKU = `PROD-${String(skuCounter).padStart(3, '0')}`;
setValue('sku', newSKU);
setSkuCounter(prev => prev + 1);
}
};
const onSubmit = async (data) => {
try {
if (isEditing) {
await productService.update(product.id, data);
toast.success('Producto actualizado');
} else {
await productService.create(data);
toast.success('Producto creado');
}
onClose();
} catch (error) {
toast.error(error.response?.data?.error || 'Error al guardar producto');
}
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-2xl font-bold text-gray-800">
{isEditing ? 'Editar Producto' : 'Nuevo Producto'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Categoría - Movido primero */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Categoría
</label>
<select
{...register('category_id')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">Sin categoría</option>
{categories && categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.nombre}
</option>
))}
</select>
</div>
{/* SKU con botón regenerar */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
SKU / Código de Barras <span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
<input
type="text"
{...register('sku', { required: 'SKU es requerido' })}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Se genera automáticamente"
disabled={isEditing}
/>
{!isEditing && (
<button
type="button"
onClick={handleRegenerateSKU}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
title="Generar nuevo SKU"
>
<RefreshCw size={20} />
</button>
)}
</div>
{errors.sku && (
<p className="mt-1 text-sm text-red-600">{errors.sku.message}</p>
)}
<p className="mt-1 text-xs text-gray-500">
{!isEditing ? 'Se genera automáticamente o puedes ingresar código de barras' : 'El SKU no puede modificarse'}
</p>
</div>
</div>
{/* Nombre */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre del Producto <span className="text-red-500">*</span>
</label>
<input
type="text"
{...register('nombre', { required: 'Nombre es requerido' })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nombre del producto"
/>
{errors.nombre && (
<p className="mt-1 text-sm text-red-600">{errors.nombre.message}</p>
)}
</div>
{/* Descripción */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Descripción
</label>
<textarea
{...register('descripcion')}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Descripción del producto"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Precio */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Precio Base <span className="text-red-500">*</span>
</label>
<input
type="number"
step="0.01"
{...register('precio_base', {
required: 'Precio es requerido',
min: { value: 0, message: 'Precio debe ser mayor a 0' }
})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="19990"
/>
{errors.precio_base && (
<p className="mt-1 text-sm text-red-600">{errors.precio_base.message}</p>
)}
</div>
{/* Peso */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Peso (gramos)
</label>
<input
type="number"
{...register('peso_gramos')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="500"
/>
</div>
</div>
{/* Dimensiones */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Alto (cm)
</label>
<input
type="number"
step="0.01"
{...register('alto_cm')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="10"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Ancho (cm)
</label>
<input
type="number"
step="0.01"
{...register('ancho_cm')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Largo (cm)
</label>
<input
type="number"
step="0.01"
{...register('largo_cm')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="30"
/>
</div>
</div>
{!isEditing && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Stock Inicial
</label>
<input
type="number"
{...register('stock_inicial', { min: 0 })}
defaultValue={0}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="0"
/>
</div>
)}
{/* Estado */}
{isEditing && (
<div className="flex items-center">
<input
type="checkbox"
{...register('is_active')}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label className="ml-2 block text-sm text-gray-700">
Producto activo
</label>
</div>
)}
{/* Galería de imágenes - Solo si el producto ya existe */}
{isEditing && product?.id && (
<ImageUploader
productId={product.id}
images={images}
onImagesChange={setImages}
/>
)}
{/* Mensaje para nuevos productos */}
{!isEditing && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
💡 Podrás subir imágenes después de crear el producto
</p>
</div>
)}
{/* Buttons */}
<div className="flex justify-end gap-4 pt-4 border-t">
<button
type="button"
onClick={onClose}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{isEditing ? 'Actualizar' : 'Crear Producto'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
export default function ProtectedRoute({ children }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}

View File

@@ -0,0 +1,150 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { X } from 'lucide-react';
import toast from 'react-hot-toast';
import { inventoryService } from '../services';
export default function StockAdjustModal({ product, onClose }) {
const { register, handleSubmit, formState: { errors }, watch } = useForm();
const [loading, setLoading] = useState(false);
const tipoMovimiento = watch('tipo_movimiento', 'entrada');
const onSubmit = async (data) => {
setLoading(true);
try {
await inventoryService.adjustStock({
product_id: product.id,
tipo_movimiento: data.tipo_movimiento,
cantidad: parseInt(data.cantidad),
motivo: data.motivo
});
toast.success('Stock ajustado correctamente');
onClose();
} catch (error) {
console.error('Error ajustando stock:', error);
toast.error(error.response?.data?.error || 'Error al ajustar stock');
} finally {
setLoading(false);
}
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-white rounded-lg shadow-xl max-w-lg w-full"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div>
<h2 className="text-2xl font-bold text-gray-800">Ajustar Stock</h2>
<p className="text-sm text-gray-600 mt-1">{product.nombre}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
{/* Stock actual */}
<div className="px-6 py-4 bg-gray-50 border-b">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Stock Actual:</span>
<span className="text-2xl font-bold text-gray-800">{product.stock_actual || 0}</span>
</div>
{product.stock_reservado > 0 && (
<div className="flex items-center justify-between mt-2">
<span className="text-sm text-gray-600">Stock Reservado:</span>
<span className="text-sm font-medium text-yellow-600">{product.stock_reservado}</span>
</div>
)}
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-6">
{/* Tipo de movimiento */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de Movimiento <span className="text-red-500">*</span>
</label>
<select
{...register('tipo_movimiento', { required: 'Tipo de movimiento es requerido' })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="entrada">Entrada (+)</option>
<option value="salida">Salida (-)</option>
<option value="ajuste">Ajuste</option>
</select>
{errors.tipo_movimiento && (
<p className="mt-1 text-sm text-red-600">{errors.tipo_movimiento.message}</p>
)}
</div>
{/* Cantidad */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cantidad <span className="text-red-500">*</span>
</label>
<input
type="number"
{...register('cantidad', {
required: 'Cantidad es requerida',
min: { value: 1, message: 'Cantidad debe ser mayor a 0' }
})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="0"
/>
{errors.cantidad && (
<p className="mt-1 text-sm text-red-600">{errors.cantidad.message}</p>
)}
</div>
{/* Motivo */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Motivo <span className="text-red-500">*</span>
</label>
<textarea
{...register('motivo', { required: 'Motivo es requerido' })}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={
tipoMovimiento === 'entrada'
? 'Ej: Compra de proveedor, Devolución de cliente'
: tipoMovimiento === 'salida'
? 'Ej: Venta, Producto dañado, Muestra'
: 'Ej: Corrección de inventario, Auditoría'
}
/>
{errors.motivo && (
<p className="mt-1 text-sm text-red-600">{errors.motivo.message}</p>
)}
</div>
{/* Buttons */}
<div className="flex justify-end gap-4 pt-4 border-t">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{loading ? 'Guardando...' : 'Ajustar Stock'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react';
import { Plus, Edit, Trash2, FolderTree } from 'lucide-react';
import toast from 'react-hot-toast';
import { categoryService } from '../services';
import CategoryModal from '../components/CategoryModal';
export default function Categories() {
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState(null);
useEffect(() => {
loadCategories();
}, []);
const loadCategories = async () => {
setLoading(true);
try {
const response = await categoryService.getTree();
setCategories(response.data || []);
} catch (err) {
console.error('Error cargando categorías:', err);
toast.error('Error cargando categorías');
setCategories([]);
} finally {
setLoading(false);
}
};
const handleEdit = (category) => {
setSelectedCategory(category);
setModalOpen(true);
};
const handleDelete = async (category) => {
if (!confirm(`¿Eliminar categoría "${category.nombre}"?`)) return;
try {
await categoryService.delete(category.id);
toast.success('Categoría eliminada');
loadCategories();
} catch (err) {
console.error('Error eliminando categoría:', err);
toast.error('Error al eliminar categoría');
}
};
const handleModalClose = () => {
setModalOpen(false);
setSelectedCategory(null);
loadCategories();
};
const renderCategory = (category, level = 0) => (
<div key={category.id}>
<div className="flex items-center justify-between p-4 bg-white border-b hover:bg-gray-50">
<div className="flex items-center gap-3" style={{ paddingLeft: `${level * 2}rem` }}>
{level > 0 && <span className="text-gray-400"></span>}
<FolderTree size={20} className="text-blue-600" />
<div>
<p className="font-medium text-gray-800">{category.nombre}</p>
{category.descripcion && (
<p className="text-sm text-gray-500">{category.descripcion}</p>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleEdit(category)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Editar"
>
<Edit size={18} />
</button>
<button
onClick={() => handleDelete(category)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Eliminar"
>
<Trash2 size={18} />
</button>
</div>
</div>
{category.children && category.children.map(child => renderCategory(child, level + 1))}
</div>
);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-800">Categorías</h1>
<p className="text-gray-600 mt-1">Organiza tus productos en categorías</p>
</div>
<button
onClick={() => setModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus size={20} />
Nueva Categoría
</button>
</div>
{/* Lista de categorías en árbol */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{!categories || categories.length === 0 ? (
<div className="text-center py-12">
<FolderTree size={48} className="mx-auto text-gray-400 mb-4" />
<p className="text-gray-600">No hay categorías creadas</p>
<button
onClick={() => setModalOpen(true)}
className="mt-4 text-blue-600 hover:text-blue-700"
>
Crear primera categoría
</button>
</div>
) : (
<div>
{categories.map(category => renderCategory(category))}
</div>
)}
</div>
{/* Modal */}
{modalOpen && (
<CategoryModal
category={selectedCategory}
categories={categories}
onClose={handleModalClose}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { Share2, Instagram, ShoppingBag, TrendingUp, Package } from 'lucide-react';
export default function Channels() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-800">Canales de Venta</h1>
<p className="text-gray-600 mt-1">Integración con MercadoLibre e Instagram Shopping</p>
</div>
{/* Placeholder */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* MercadoLibre */}
<div className="bg-white rounded-lg shadow p-8">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-yellow-100 rounded-full mb-4">
<ShoppingBag size={32} className="text-yellow-600" />
</div>
<h2 className="text-xl font-semibold text-gray-800 mb-2">MercadoLibre</h2>
<p className="text-gray-600 mb-4">
Sincroniza productos con markup del 10%
</p>
<div className="space-y-3 mt-6">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
<span className="text-sm text-gray-600">Estado</span>
<span className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-xs font-medium">
No configurado
</span>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
<span className="text-sm text-gray-600">Productos sincronizados</span>
<span className="font-semibold">0</span>
</div>
</div>
<button className="mt-6 w-full px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors">
Configurar Integración
</button>
</div>
</div>
{/* Instagram Shopping */}
<div className="bg-white rounded-lg shadow p-8">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-pink-100 rounded-full mb-4">
<Instagram size={32} className="text-pink-600" />
</div>
<h2 className="text-xl font-semibold text-gray-800 mb-2">Instagram Shopping</h2>
<p className="text-gray-600 mb-4">
Catálogo sincronizado con Facebook
</p>
<div className="space-y-3 mt-6">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
<span className="text-sm text-gray-600">Estado</span>
<span className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-xs font-medium">
No configurado
</span>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
<span className="text-sm text-gray-600">Productos en catálogo</span>
<span className="font-semibold">0</span>
</div>
</div>
<button className="mt-6 w-full px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors">
Configurar Integración
</button>
</div>
</div>
</div>
{/* Features */}
<div className="bg-white rounded-lg shadow p-8">
<h3 className="text-lg font-semibold text-gray-800 mb-6">Funcionalidades Multi-Canal</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-start gap-3">
<Share2 size={24} className="text-blue-600 flex-shrink-0 mt-1" />
<div>
<h4 className="font-medium text-gray-800 mb-1">Sincronización Automática</h4>
<p className="text-sm text-gray-600">
Los productos se publican automáticamente en todos los canales
</p>
</div>
</div>
<div className="flex items-start gap-3">
<TrendingUp size={24} className="text-green-600 flex-shrink-0 mt-1" />
<div>
<h4 className="font-medium text-gray-800 mb-1">Markup Inteligente</h4>
<p className="text-sm text-gray-600">
Ajuste de precios por canal (MercadoLibre +10%)
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Package size={24} className="text-purple-600 flex-shrink-0 mt-1" />
<div>
<h4 className="font-medium text-gray-800 mb-1">Stock Unificado</h4>
<p className="text-sm text-gray-600">
Inventario centralizado para todos los canales
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import { Package, ShoppingCart, TruckIcon, AlertCircle, TrendingUp, DollarSign } from 'lucide-react';
import { productService, inventoryService } from '../services';
export default function Dashboard() {
const [stats, setStats] = useState({
totalProductos: 0,
productosActivos: 0,
ordenesHoy: 0,
stockBajo: 0,
ventasHoy: 0
});
const [loading, setLoading] = useState(true);
useEffect(() => {
loadStats();
}, []);
const loadStats = async () => {
try {
const [products, lowStock] = await Promise.all([
productService.getAll({ limit: 100 }),
inventoryService.getLowStock()
]);
setStats({
totalProductos: products.pagination.totalItems,
productosActivos: products.data.filter(p => p.is_active).length,
ordenesHoy: 0, // TODO: implementar
stockBajo: lowStock.products.length,
ventasHoy: 0 // TODO: implementar
});
} catch (error) {
console.error('Error cargando estadísticas:', error);
} finally {
setLoading(false);
}
};
const StatCard = ({ icon: Icon, title, value, color, subtext }) => {
const IconComponent = Icon;
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">{title}</p>
<p className="text-3xl font-bold text-gray-800 mt-2">{value}</p>
{subtext && <p className="text-sm text-gray-500 mt-1">{subtext}</p>}
</div>
<div className={`p-4 rounded-full ${color}`}>
<IconComponent size={28} className="text-white" />
</div>
</div>
</div>
);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-800">Dashboard</h1>
<button
onClick={loadStats}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Actualizar
</button>
</div>
{/* Estadísticas principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
icon={Package}
title="Total Productos"
value={stats.totalProductos}
color="bg-blue-500"
subtext={`${stats.productosActivos} activos`}
/>
<StatCard
icon={ShoppingCart}
title="Órdenes Hoy"
value={stats.ordenesHoy}
color="bg-green-500"
/>
<StatCard
icon={DollarSign}
title="Ventas Hoy"
value={`$${stats.ventasHoy.toLocaleString()}`}
color="bg-purple-500"
/>
<StatCard
icon={AlertCircle}
title="Stock Bajo"
value={stats.stockBajo}
color="bg-red-500"
subtext="Requieren atención"
/>
</div>
{/* Alertas */}
{stats.stockBajo > 0 && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded">
<div className="flex">
<AlertCircle className="text-yellow-400 mr-3" size={24} />
<div>
<h3 className="text-sm font-medium text-yellow-800">
Atención: Productos con stock bajo
</h3>
<p className="text-sm text-yellow-700 mt-1">
Hay {stats.stockBajo} producto(s) que requieren reposición.
</p>
</div>
</div>
</div>
)}
{/* Gráficos y tablas */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Ventas Recientes</h3>
<p className="text-gray-500 text-center py-8">Sin órdenes recientes</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Productos Más Vendidos</h3>
<p className="text-gray-500 text-center py-8">Sin datos disponibles</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { useEffect, useState } from 'react';
import { Package, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react';
import toast from 'react-hot-toast';
import { productService } from '../services';
import StockAdjustModal from '../components/StockAdjustModal';
export default function Inventory() {
const [inventory, setInventory] = useState([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [selectedProduct, setSelectedProduct] = useState(null);
const [filter, setFilter] = useState('all'); // all, low, out
useEffect(() => {
loadInventory();
}, []);
const loadInventory = async () => {
setLoading(true);
try {
const response = await productService.getAll({ limit: 100 });
setInventory(response.data);
} catch (err) {
console.error('Error cargando inventario:', err);
toast.error('Error cargando inventario');
} finally {
setLoading(false);
}
};
const handleAdjust = (product) => {
setSelectedProduct(product);
setModalOpen(true);
};
const handleModalClose = () => {
setModalOpen(false);
setSelectedProduct(null);
loadInventory();
};
const getStockStatus = (stock, minStock = 10) => {
if (stock === 0) return { color: 'text-red-600 bg-red-50', icon: AlertTriangle, text: 'Agotado' };
if (stock <= minStock) return { color: 'text-yellow-600 bg-yellow-50', icon: TrendingDown, text: 'Stock Bajo' };
return { color: 'text-green-600 bg-green-50', icon: TrendingUp, text: 'Normal' };
};
const filteredInventory = inventory.filter(item => {
if (filter === 'low') return item.stock_actual > 0 && item.stock_actual <= (item.stock_minimo || 10);
if (filter === 'out') return item.stock_actual === 0;
return true;
});
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-800">Inventario</h1>
<p className="text-gray-600 mt-1">Control de stock y movimientos</p>
</div>
</div>
{/* Filtros */}
<div className="flex gap-4">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'all'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50 border'
}`}
>
Todos ({inventory.length})
</button>
<button
onClick={() => setFilter('low')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'low'
? 'bg-yellow-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50 border'
}`}
>
Stock Bajo ({inventory.filter(i => i.stock_actual > 0 && i.stock_actual <= 10).length})
</button>
<button
onClick={() => setFilter('out')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === 'out'
? 'bg-red-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50 border'
}`}
>
Agotados ({inventory.filter(i => i.stock_actual === 0).length})
</button>
</div>
{/* Tabla de inventario */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Producto
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
SKU
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Stock Actual
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Stock Reservado
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredInventory.length === 0 ? (
<tr>
<td colSpan="6" className="px-6 py-12 text-center text-gray-500">
No hay productos que mostrar
</td>
</tr>
) : (
filteredInventory.map((product) => {
const status = getStockStatus(product.stock_actual, product.stock_minimo);
const StatusIcon = status.icon;
return (
<tr key={product.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{product.imagen_principal ? (
<img
src={`http://localhost:3001${product.imagen_principal}`}
alt={product.nombre}
className="h-10 w-10 rounded object-cover"
/>
) : (
<div className="h-10 w-10 bg-gray-200 rounded flex items-center justify-center">
<Package size={20} className="text-gray-400" />
</div>
)}
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{product.nombre}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-500">{product.sku}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-semibold text-gray-900">{product.stock_actual || 0}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-500">{product.stock_reservado || 0}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.color}`}>
<StatusIcon size={14} />
{status.text}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button
onClick={() => handleAdjust(product)}
className="text-blue-600 hover:text-blue-900 font-medium"
>
Ajustar Stock
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Modal */}
{modalOpen && selectedProduct && (
<StockAdjustModal
product={selectedProduct}
onClose={handleModalClose}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { authService } from '../services';
import { useAuthStore } from '../store/authStore';
import { LogIn } from 'lucide-react';
export default function Login() {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { login } = useAuthStore();
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = async (data) => {
setLoading(true);
try {
const response = await authService.login(data.email, data.password);
console.log('Login response:', response);
if (response.user.role !== 'admin') {
toast.error('No tienes permisos de administrador');
setLoading(false);
return;
}
login(response.user, response.token);
toast.success('¡Bienvenido!');
navigate('/dashboard');
} catch (error) {
console.error('Login error:', error);
toast.error(error.response?.data?.error || 'Error al iniciar sesión');
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600">
<div className="bg-white rounded-lg shadow-2xl p-8 w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-block p-3 bg-blue-100 rounded-full mb-4">
<LogIn size={32} className="text-blue-600" />
</div>
<h1 className="text-3xl font-bold text-gray-800">OfertaWeb</h1>
<p className="text-gray-600 mt-2">Panel de Administración</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Correo electrónico
</label>
<input
type="email"
{...register('email', { required: 'Email es requerido' })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="admin@ofertaweb.cl"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contraseña
</label>
<input
type="password"
{...register('password', { required: 'Contraseña es requerida' })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••••"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
</button>
</form>
<div className="mt-6 text-center text-sm text-gray-600">
<p>Usuario de prueba: <strong>admin@ofertaweb.cl</strong></p>
<p>Contraseña: <strong>admin123</strong></p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react';
import { ShoppingCart, Eye, Package, Truck, CheckCircle, XCircle } from 'lucide-react';
import toast from 'react-hot-toast';
import { orderService } from '../services';
export default function Orders() {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
useEffect(() => {
loadOrders();
}, []);
const loadOrders = async () => {
setLoading(true);
try {
const response = await orderService.getAll();
setOrders(response.data);
} catch (err) {
console.error('Error cargando órdenes:', err);
toast.error('Error cargando órdenes');
} finally {
setLoading(false);
}
};
const handleStatusChange = async (orderId, newStatus) => {
try {
await orderService.updateStatus(orderId, newStatus);
toast.success('Estado actualizado');
loadOrders();
} catch (err) {
console.error('Error actualizando estado:', err);
toast.error('Error al actualizar estado');
}
};
const getStatusInfo = (status) => {
const statusMap = {
pendiente: { color: 'bg-yellow-100 text-yellow-800', icon: ShoppingCart, text: 'Pendiente' },
confirmada: { color: 'bg-blue-100 text-blue-800', icon: CheckCircle, text: 'Confirmada' },
preparando: { color: 'bg-purple-100 text-purple-800', icon: Package, text: 'Preparando' },
enviada: { color: 'bg-indigo-100 text-indigo-800', icon: Truck, text: 'Enviada' },
entregada: { color: 'bg-green-100 text-green-800', icon: CheckCircle, text: 'Entregada' },
cancelada: { color: 'bg-red-100 text-red-800', icon: XCircle, text: 'Cancelada' }
};
return statusMap[status] || statusMap.pendiente;
};
const filteredOrders = orders.filter(order => {
if (filter === 'all') return true;
return order.estado === filter;
});
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-800">Órdenes</h1>
<p className="text-gray-600 mt-1">Gestiona las órdenes de tus clientes</p>
</div>
{/* Filtros */}
<div className="flex gap-2 overflow-x-auto">
{['all', 'pendiente', 'confirmada', 'preparando', 'enviada', 'entregada', 'cancelada'].map((status) => (
<button
key={status}
onClick={() => setFilter(status)}
className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-colors ${
filter === status
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50 border'
}`}
>
{status === 'all' ? 'Todas' : getStatusInfo(status).text}
</button>
))}
</div>
{/* Tabla de órdenes */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Orden</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cliente</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Total</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Estado</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Fecha</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Acciones</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredOrders.length === 0 ? (
<tr>
<td colSpan="6" className="px-6 py-12 text-center text-gray-500">
No hay órdenes para mostrar
</td>
</tr>
) : (
filteredOrders.map((order) => {
const statusInfo = getStatusInfo(order.estado);
const StatusIcon = statusInfo.icon;
return (
<tr key={order.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-gray-900">#{order.numero_orden}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{order.nombre_cliente}</div>
<div className="text-sm text-gray-500">{order.email_cliente}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-semibold text-gray-900">
${order.total?.toLocaleString('es-CL')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
<StatusIcon size={14} />
{statusInfo.text}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(order.created_at).toLocaleDateString('es-CL')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<select
value={order.estado}
onChange={(e) => handleStatusChange(order.id, e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm"
>
<option value="pendiente">Pendiente</option>
<option value="confirmada">Confirmada</option>
<option value="preparando">Preparando</option>
<option value="enviada">Enviada</option>
<option value="entregada">Entregada</option>
<option value="cancelada">Cancelada</option>
</select>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,270 @@
import { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, Image as ImageIcon, Printer } from 'lucide-react';
import toast from 'react-hot-toast';
import { productService, categoryService } from '../services';
import ProductModal from '../components/ProductModal';
import PrintLabel from '../components/PrintLabel';
export default function Products() {
const [products, setProducts] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [printModalOpen, setPrintModalOpen] = useState(false);
const [selectedProduct, setSelectedProduct] = useState(null);
const [search, setSearch] = useState('');
const [pagination, setPagination] = useState({});
useEffect(() => {
loadProducts();
loadCategories();
}, []);
const loadProducts = async (searchTerm = '') => {
setLoading(true);
try {
const response = await productService.getAll({ search: searchTerm, limit: 50 });
setProducts(response.data);
setPagination(response.pagination);
} catch (err) {
console.error('Error cargando productos:', err);
toast.error('Error cargando productos');
} finally {
setLoading(false);
}
};
const loadCategories = async () => {
try {
const response = await categoryService.getAll();
setCategories(response.data);
} catch (err) {
console.error('Error cargando categorías:', err);
}
};
const handleSearch = (e) => {
const value = e.target.value;
setSearch(value);
if (value.length > 2 || value.length === 0) {
loadProducts(value);
}
};
const handleEdit = (product) => {
setSelectedProduct(product);
setModalOpen(true);
};
const handlePrint = (product) => {
setSelectedProduct(product);
setPrintModalOpen(true);
};
const handleDelete = async (product) => {
if (!confirm(`¿Eliminar producto "${product.nombre}"?`)) return;
try {
await productService.delete(product.id);
toast.success('Producto eliminado');
loadProducts();
} catch (err) {
console.error('Error eliminando producto:', err);
toast.error('Error al eliminar producto');
}
};
const handleModalClose = () => {
setModalOpen(false);
setSelectedProduct(null);
loadProducts();
};
const getCategoryName = (categoryId) => {
if (!categories || categories.length === 0) return 'Sin categoría';
const category = categories.find(c => c.id === categoryId);
return category?.nombre || 'Sin categoría';
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-800">Productos</h1>
<button
onClick={() => setModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus size={20} />
Nuevo Producto
</button>
</div>
{/* Search Bar */}
<div className="bg-white rounded-lg shadow p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
value={search}
onChange={handleSearch}
placeholder="Buscar productos por nombre, SKU o descripción..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{/* Products Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : products.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No hay productos registrados</p>
<button
onClick={() => setModalOpen(true)}
className="mt-4 text-blue-600 hover:text-blue-700 font-medium"
>
Crear primer producto
</button>
</div>
) : (
<>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Producto
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
SKU
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Categoría
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Precio
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Stock
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{products.map((product) => (
<tr key={product.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="h-10 w-10 flex-shrink-0 bg-gray-200 rounded flex items-center justify-center">
{product.images?.length > 0 ? (
<img
src={`http://localhost:3001${product.images[0].url}`}
alt={product.nombre}
className="h-10 w-10 rounded object-cover"
/>
) : (
<ImageIcon size={20} className="text-gray-400" />
)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{product.nombre}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-900 font-mono">{product.sku}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">{getCategoryName(product.category_id)}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-semibold text-gray-900">
${product.precio_base.toLocaleString('es-CL')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`text-sm font-medium ${
product.stock_disponible === 0 ? 'text-red-600' :
product.stock_disponible < 10 ? 'text-yellow-600' :
'text-green-600'
}`}>
{product.stock_disponible || 0}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
product.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{product.is_active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handlePrint(product)}
className="text-gray-600 hover:text-gray-900 mr-3"
title="Imprimir etiqueta"
>
<Printer size={18} />
</button>
<button
onClick={() => handleEdit(product)}
className="text-blue-600 hover:text-blue-900 mr-3"
title="Editar"
>
<Edit size={18} />
</button>
<button
onClick={() => handleDelete(product)}
className="text-red-600 hover:text-red-900"
title="Eliminar"
>
<Trash2 size={18} />
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination Info */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<p className="text-sm text-gray-700">
Mostrando {products.length} de {pagination.totalItems} productos
</p>
</div>
</>
)}
</div>
{/* Modal */}
{modalOpen && (
<ProductModal
product={selectedProduct}
categories={categories}
onClose={handleModalClose}
/>
)}
{/* Modal de impresión */}
{printModalOpen && selectedProduct && (
<PrintLabel
product={selectedProduct}
onClose={() => {
setPrintModalOpen(false);
setSelectedProduct(null);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { Settings, Store, CreditCard, Bell, Shield, Database } from 'lucide-react';
export default function SettingsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-800">Configuración</h1>
<p className="text-gray-600 mt-1">Ajustes generales del sistema</p>
</div>
{/* Settings Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Información General */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Store size={24} className="text-blue-600" />
<h2 className="text-lg font-semibold text-gray-800">Información de la Tienda</h2>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-600 mb-1">Nombre de la tienda</label>
<input
type="text"
defaultValue="OfertaWeb"
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
disabled
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Email de contacto</label>
<input
type="email"
defaultValue="contacto@ofertaweb.cl"
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
disabled
/>
</div>
</div>
</div>
{/* Métodos de Pago */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<CreditCard size={24} className="text-green-600" />
<h2 className="text-lg font-semibold text-gray-800">Métodos de Pago</h2>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
<span className="text-sm font-medium">Webpay/Transbank</span>
<span className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-xs">
No configurado
</span>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded">
<span className="text-sm font-medium">Transferencia Bancaria</span>
<span className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-xs">
No configurado
</span>
</div>
</div>
</div>
{/* Notificaciones */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Bell size={24} className="text-yellow-600" />
<h2 className="text-lg font-semibold text-gray-800">Notificaciones</h2>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3">
<input type="checkbox" defaultChecked className="rounded" disabled />
<span className="text-sm text-gray-700">Email por nueva orden</span>
</label>
<label className="flex items-center gap-3">
<input type="checkbox" defaultChecked className="rounded" disabled />
<span className="text-sm text-gray-700">Alerta de stock bajo</span>
</label>
<label className="flex items-center gap-3">
<input type="checkbox" className="rounded" disabled />
<span className="text-sm text-gray-700">Reportes semanales</span>
</label>
</div>
</div>
{/* Seguridad */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Shield size={24} className="text-red-600" />
<h2 className="text-lg font-semibold text-gray-800">Seguridad</h2>
</div>
<div className="space-y-3">
<button className="w-full text-left px-3 py-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors text-sm">
Cambiar contraseña
</button>
<button className="w-full text-left px-3 py-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors text-sm">
Gestionar usuarios
</button>
<button className="w-full text-left px-3 py-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors text-sm">
Tokens de API
</button>
</div>
</div>
{/* Base de Datos */}
<div className="bg-white rounded-lg shadow p-6 md:col-span-2">
<div className="flex items-center gap-3 mb-4">
<Database size={24} className="text-purple-600" />
<h2 className="text-lg font-semibold text-gray-800">Base de Datos</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-gray-50 rounded">
<div className="text-2xl font-bold text-gray-800 mb-1">PostgreSQL</div>
<div className="text-sm text-gray-600">Servidor de base de datos</div>
</div>
<div className="p-4 bg-gray-50 rounded">
<div className="text-2xl font-bold text-gray-800 mb-1">ofertaweb</div>
<div className="text-sm text-gray-600">Nombre de la base de datos</div>
</div>
<div className="p-4 bg-gray-50 rounded">
<div className="text-2xl font-bold text-green-600 mb-1">Conectado</div>
<div className="text-sm text-gray-600">Estado de conexión</div>
</div>
</div>
</div>
</div>
{/* Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<Settings size={20} className="text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-blue-900 mb-1">Configuración en Desarrollo</h3>
<p className="text-sm text-blue-700">
Esta sección está en construcción. Las configuraciones se cargarán desde variables de entorno y base de datos.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Truck, Package, MapPin, Clock } from 'lucide-react';
export default function Shipments() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-800">Envíos y Despachos</h1>
<p className="text-gray-600 mt-1">Gestión de envíos y tracking de entregas</p>
</div>
{/* Placeholder */}
<div className="bg-white rounded-lg shadow p-12">
<div className="text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-blue-100 rounded-full mb-4">
<Truck size={40} className="text-blue-600" />
</div>
<h2 className="text-xl font-semibold text-gray-800 mb-2">Módulo de Envíos</h2>
<p className="text-gray-600 mb-6">
Próximamente: Integración con BlueExpress para gestión de envíos
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8 max-w-3xl mx-auto">
<div className="p-6 bg-gray-50 rounded-lg">
<Package size={32} className="text-blue-600 mx-auto mb-3" />
<h3 className="font-semibold text-gray-800 mb-2">Crear Envíos</h3>
<p className="text-sm text-gray-600">Genera órdenes de envío automáticas</p>
</div>
<div className="p-6 bg-gray-50 rounded-lg">
<MapPin size={32} className="text-blue-600 mx-auto mb-3" />
<h3 className="font-semibold text-gray-800 mb-2">Tracking</h3>
<p className="text-sm text-gray-600">Seguimiento en tiempo real</p>
</div>
<div className="p-6 bg-gray-50 rounded-lg">
<Clock size={32} className="text-blue-600 mx-auto mb-3" />
<h3 className="font-semibold text-gray-800 mb-2">Historial</h3>
<p className="text-sm text-gray-600">Registro de todos los envíos</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001/api',
headers: {
'Content-Type': 'application/json'
}
});
// Interceptor para agregar el token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor para manejar errores
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;

View File

@@ -0,0 +1,118 @@
import api from './api';
export const authService = {
login: async (email, password) => {
const response = await api.post('/auth/login', { email, password });
return response.data;
},
register: async (userData) => {
const response = await api.post('/auth/register', userData);
return response.data;
},
getProfile: async () => {
const response = await api.get('/auth/profile');
return response.data;
}
};
export const productService = {
getAll: async (params) => {
const response = await api.get('/products', { params });
return response.data;
},
getById: async (id) => {
const response = await api.get(`/products/${id}`);
return response.data;
},
create: async (data) => {
const response = await api.post('/products', data);
return response.data;
},
update: async (id, data) => {
const response = await api.put(`/products/${id}`, data);
return response.data;
},
delete: async (id) => {
const response = await api.delete(`/products/${id}`);
return response.data;
},
addImage: async (id, formData) => {
const response = await api.post(`/products/${id}/images`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return response.data;
}
};
export const categoryService = {
getAll: async () => {
const response = await api.get('/categories');
return response.data;
},
getTree: async () => {
const response = await api.get('/categories/tree');
return response.data;
},
create: async (data) => {
const response = await api.post('/categories', data);
return response.data;
},
update: async (id, data) => {
const response = await api.put(`/categories/${id}`, data);
return response.data;
},
delete: async (id) => {
const response = await api.delete(`/categories/${id}`);
return response.data;
}
};
export const inventoryService = {
getByProduct: async (productId) => {
const response = await api.get(`/inventory/product/${productId}`);
return response.data;
},
adjustStock: async (data) => {
const response = await api.post('/inventory/adjust', data);
return response.data;
},
getMovements: async (productId) => {
const response = await api.get(`/inventory/product/${productId}/movements`);
return response.data;
},
getLowStock: async () => {
const response = await api.get('/inventory/low-stock');
return response.data;
}
};
export const orderService = {
getAll: async (params) => {
const response = await api.get('/orders', { params });
return response.data;
},
getById: async (id) => {
const response = await api.get(`/orders/${id}`);
return response.data;
},
updateStatus: async (id, estado) => {
const response = await api.put(`/orders/${id}/status`, { estado });
return response.data;
}
};

View File

@@ -0,0 +1,29 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useAuthStore = create(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: (user, token) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
set({ user, token, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
set({ user: null, token: null, isAuthenticated: false });
},
setUser: (user) => set({ user })
}),
{
name: 'auth-storage'
}
)
);

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})