Spaces:
Running
Running
Upload 51 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +10 -0
- .gitattributes +1 -0
- Dockerfile +32 -0
- README.md +18 -5
- frontend/.gitignore +24 -0
- frontend/README.md +16 -0
- frontend/build.log +0 -0
- frontend/eslint.config.js +29 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +32 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/icons.svg +24 -0
- frontend/public/icons/inventory.png +0 -0
- frontend/public/images/burger.png +0 -0
- frontend/public/images/cafe.png +0 -0
- frontend/public/images/cerveza.png +0 -0
- frontend/public/images/limonada.png +0 -0
- frontend/public/images/pasta.png +0 -0
- frontend/public/images/paste.png +0 -0
- frontend/public/images/pizza.png +0 -0
- frontend/public/images/salad.png +0 -0
- frontend/public/images/tacos.jpg +3 -0
- frontend/public/images/tacos.png +0 -0
- frontend/src/App.css +184 -0
- frontend/src/App.jsx +66 -0
- frontend/src/assets/hero.png +0 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/assets/vite.svg +1 -0
- frontend/src/components/ProtectedRoute.jsx +19 -0
- frontend/src/components/admin/CRMManager.jsx +129 -0
- frontend/src/components/admin/DashboardOverview.jsx +71 -0
- frontend/src/components/admin/FinanceManager.jsx +192 -0
- frontend/src/components/admin/InventoryControl.jsx +192 -0
- frontend/src/components/admin/MenuEditor.jsx +231 -0
- frontend/src/components/admin/Reports.jsx +162 -0
- frontend/src/components/admin/SettingsPlus.jsx +115 -0
- frontend/src/components/admin/TableManager.jsx +139 -0
- frontend/src/components/admin/UserManager.jsx +109 -0
- frontend/src/context/AuthContext.jsx +64 -0
- frontend/src/firebase/config.js +22 -0
- frontend/src/index.css +190 -0
- frontend/src/main.jsx +10 -0
- frontend/src/pages/AdminDashboard.jsx +94 -0
- frontend/src/pages/CustomerMenu.jsx +210 -0
- frontend/src/pages/KitchenView.jsx +126 -0
- frontend/src/pages/Login.jsx +181 -0
- frontend/src/pages/WaiterPOS.jsx +453 -0
- frontend/vite.config.js +7 -0
- package.json +32 -0
.dockerignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
.git
|
| 4 |
+
.gitignore
|
| 5 |
+
README.md
|
| 6 |
+
build.log
|
| 7 |
+
eslint.config.js
|
| 8 |
+
package-lock.json (ignore local ones to let HF generate their own or use the one copied)
|
| 9 |
+
# Keep package-lock.json if you want identical versions, but usually it's better to let HF install fresh if they have a different arch.
|
| 10 |
+
# Actually, keep package-lock.json for stability.
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
frontend/public/images/tacos.jpg filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-alpine AS build
|
| 2 |
+
|
| 3 |
+
# Set the working directory
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Copy package files first for better caching
|
| 7 |
+
COPY frontend/package*.json ./
|
| 8 |
+
|
| 9 |
+
# Install dependencies
|
| 10 |
+
RUN npm install
|
| 11 |
+
|
| 12 |
+
# Copy the rest of the application
|
| 13 |
+
COPY frontend/ .
|
| 14 |
+
|
| 15 |
+
# Build the application
|
| 16 |
+
RUN npm run build
|
| 17 |
+
|
| 18 |
+
# Second stage: Serve the application with a lightweight HTTP server
|
| 19 |
+
FROM node:22-alpine
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Install 'serve' tool to host static files
|
| 23 |
+
RUN npm install -g serve
|
| 24 |
+
|
| 25 |
+
# Copy only the built files from the build stage
|
| 26 |
+
COPY --from=build /app/dist ./dist
|
| 27 |
+
|
| 28 |
+
# Hugging Face Spaces expect port 7860
|
| 29 |
+
EXPOSE 7860
|
| 30 |
+
|
| 31 |
+
# Command to serve the static site on port 7860
|
| 32 |
+
CMD ["serve", "-s", "dist", "-l", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,23 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Resto OS Premium
|
| 3 |
+
emoji: 🍴
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Resto OS Premium
|
| 11 |
+
|
| 12 |
+
Sistema de gestión de restaurantes premium con carta digital QR y panel pos.
|
| 13 |
+
|
| 14 |
+
## Despliegue en Hugging Face
|
| 15 |
+
Este repositorio está configurado para un **Docker Space**.
|
| 16 |
+
Hugging Face detectará automáticamente el `Dockerfile` y realizará la construcción de la aplicación React de forma automática.
|
| 17 |
+
|
| 18 |
+
## Características
|
| 19 |
+
- Gestión de Menú con Imágenes (Firebase Storage)
|
| 20 |
+
- Control de Inventario
|
| 21 |
+
- Punto de Venta (POS) para Meseros
|
| 22 |
+
- Vista de Cocina en tiempo real
|
| 23 |
+
- Carta Digital QR con Temas Dinámicos
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
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).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
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.
|
frontend/build.log
ADDED
|
Binary file (17.7 kB). View file
|
|
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs.flat.recommended,
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
ecmaVersion: 2020,
|
| 18 |
+
globals: globals.browser,
|
| 19 |
+
parserOptions: {
|
| 20 |
+
ecmaVersion: 'latest',
|
| 21 |
+
ecmaFeatures: { jsx: true },
|
| 22 |
+
sourceType: 'module',
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
rules: {
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"chart.js": "^4.5.1",
|
| 14 |
+
"firebase": "^12.11.0",
|
| 15 |
+
"lucide-react": "^0.577.0",
|
| 16 |
+
"react": "^19.2.4",
|
| 17 |
+
"react-chartjs-2": "^5.3.1",
|
| 18 |
+
"react-dom": "^19.2.4",
|
| 19 |
+
"react-router-dom": "^7.13.1"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@eslint/js": "^9.39.4",
|
| 23 |
+
"@types/react": "^19.2.14",
|
| 24 |
+
"@types/react-dom": "^19.2.3",
|
| 25 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 26 |
+
"eslint": "^9.39.4",
|
| 27 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 28 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 29 |
+
"globals": "^17.4.0",
|
| 30 |
+
"vite": "^6.0.0"
|
| 31 |
+
}
|
| 32 |
+
}
|
frontend/public/favicon.svg
ADDED
|
|
frontend/public/icons.svg
ADDED
|
|
frontend/public/icons/inventory.png
ADDED
|
|
frontend/public/images/burger.png
ADDED
|
frontend/public/images/cafe.png
ADDED
|
frontend/public/images/cerveza.png
ADDED
|
frontend/public/images/limonada.png
ADDED
|
frontend/public/images/pasta.png
ADDED
|
frontend/public/images/paste.png
ADDED
|
frontend/public/images/pizza.png
ADDED
|
frontend/public/images/salad.png
ADDED
|
frontend/public/images/tacos.jpg
ADDED
|
Git LFS Details
|
frontend/public/images/tacos.png
ADDED
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.counter {
|
| 2 |
+
font-size: 16px;
|
| 3 |
+
padding: 5px 10px;
|
| 4 |
+
border-radius: 5px;
|
| 5 |
+
color: var(--accent);
|
| 6 |
+
background: var(--accent-bg);
|
| 7 |
+
border: 2px solid transparent;
|
| 8 |
+
transition: border-color 0.3s;
|
| 9 |
+
margin-bottom: 24px;
|
| 10 |
+
|
| 11 |
+
&:hover {
|
| 12 |
+
border-color: var(--accent-border);
|
| 13 |
+
}
|
| 14 |
+
&:focus-visible {
|
| 15 |
+
outline: 2px solid var(--accent);
|
| 16 |
+
outline-offset: 2px;
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.hero {
|
| 21 |
+
position: relative;
|
| 22 |
+
|
| 23 |
+
.base,
|
| 24 |
+
.framework,
|
| 25 |
+
.vite {
|
| 26 |
+
inset-inline: 0;
|
| 27 |
+
margin: 0 auto;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.base {
|
| 31 |
+
width: 170px;
|
| 32 |
+
position: relative;
|
| 33 |
+
z-index: 0;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.framework,
|
| 37 |
+
.vite {
|
| 38 |
+
position: absolute;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.framework {
|
| 42 |
+
z-index: 1;
|
| 43 |
+
top: 34px;
|
| 44 |
+
height: 28px;
|
| 45 |
+
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
| 46 |
+
scale(1.4);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.vite {
|
| 50 |
+
z-index: 0;
|
| 51 |
+
top: 107px;
|
| 52 |
+
height: 26px;
|
| 53 |
+
width: auto;
|
| 54 |
+
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
| 55 |
+
scale(0.8);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
#center {
|
| 60 |
+
display: flex;
|
| 61 |
+
flex-direction: column;
|
| 62 |
+
gap: 25px;
|
| 63 |
+
place-content: center;
|
| 64 |
+
place-items: center;
|
| 65 |
+
flex-grow: 1;
|
| 66 |
+
|
| 67 |
+
@media (max-width: 1024px) {
|
| 68 |
+
padding: 32px 20px 24px;
|
| 69 |
+
gap: 18px;
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
#next-steps {
|
| 74 |
+
display: flex;
|
| 75 |
+
border-top: 1px solid var(--border);
|
| 76 |
+
text-align: left;
|
| 77 |
+
|
| 78 |
+
& > div {
|
| 79 |
+
flex: 1 1 0;
|
| 80 |
+
padding: 32px;
|
| 81 |
+
@media (max-width: 1024px) {
|
| 82 |
+
padding: 24px 20px;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.icon {
|
| 87 |
+
margin-bottom: 16px;
|
| 88 |
+
width: 22px;
|
| 89 |
+
height: 22px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
@media (max-width: 1024px) {
|
| 93 |
+
flex-direction: column;
|
| 94 |
+
text-align: center;
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
#docs {
|
| 99 |
+
border-right: 1px solid var(--border);
|
| 100 |
+
|
| 101 |
+
@media (max-width: 1024px) {
|
| 102 |
+
border-right: none;
|
| 103 |
+
border-bottom: 1px solid var(--border);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
#next-steps ul {
|
| 108 |
+
list-style: none;
|
| 109 |
+
padding: 0;
|
| 110 |
+
display: flex;
|
| 111 |
+
gap: 8px;
|
| 112 |
+
margin: 32px 0 0;
|
| 113 |
+
|
| 114 |
+
.logo {
|
| 115 |
+
height: 18px;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
a {
|
| 119 |
+
color: var(--text-h);
|
| 120 |
+
font-size: 16px;
|
| 121 |
+
border-radius: 6px;
|
| 122 |
+
background: var(--social-bg);
|
| 123 |
+
display: flex;
|
| 124 |
+
padding: 6px 12px;
|
| 125 |
+
align-items: center;
|
| 126 |
+
gap: 8px;
|
| 127 |
+
text-decoration: none;
|
| 128 |
+
transition: box-shadow 0.3s;
|
| 129 |
+
|
| 130 |
+
&:hover {
|
| 131 |
+
box-shadow: var(--shadow);
|
| 132 |
+
}
|
| 133 |
+
.button-icon {
|
| 134 |
+
height: 18px;
|
| 135 |
+
width: 18px;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
@media (max-width: 1024px) {
|
| 140 |
+
margin-top: 20px;
|
| 141 |
+
flex-wrap: wrap;
|
| 142 |
+
justify-content: center;
|
| 143 |
+
|
| 144 |
+
li {
|
| 145 |
+
flex: 1 1 calc(50% - 8px);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
a {
|
| 149 |
+
width: 100%;
|
| 150 |
+
justify-content: center;
|
| 151 |
+
box-sizing: border-box;
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
#spacer {
|
| 157 |
+
height: 88px;
|
| 158 |
+
border-top: 1px solid var(--border);
|
| 159 |
+
@media (max-width: 1024px) {
|
| 160 |
+
height: 48px;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.ticks {
|
| 165 |
+
position: relative;
|
| 166 |
+
width: 100%;
|
| 167 |
+
|
| 168 |
+
&::before,
|
| 169 |
+
&::after {
|
| 170 |
+
content: '';
|
| 171 |
+
position: absolute;
|
| 172 |
+
top: -4.5px;
|
| 173 |
+
border: 5px solid transparent;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
&::before {
|
| 177 |
+
left: 0;
|
| 178 |
+
border-left-color: var(--border);
|
| 179 |
+
}
|
| 180 |
+
&::after {
|
| 181 |
+
right: 0;
|
| 182 |
+
border-right-color: var(--border);
|
| 183 |
+
}
|
| 184 |
+
}
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
| 3 |
+
import { AuthProvider, useAuth } from './context/AuthContext';
|
| 4 |
+
import ProtectedRoute from './components/ProtectedRoute';
|
| 5 |
+
|
| 6 |
+
// Páginas
|
| 7 |
+
import Login from './pages/Login';
|
| 8 |
+
import AdminDashboard from './pages/AdminDashboard';
|
| 9 |
+
import WaiterPOS from './pages/WaiterPOS';
|
| 10 |
+
import CustomerMenu from './pages/CustomerMenu';
|
| 11 |
+
import KitchenView from './pages/KitchenView';
|
| 12 |
+
|
| 13 |
+
// Router base para redirigir dependiendo del rol al loguearse
|
| 14 |
+
function RootRedirect() {
|
| 15 |
+
const { currentUser, userRole, loading } = useAuth();
|
| 16 |
+
if (loading) return <div className="app-container" style={{justifyContent:'center', alignItems:'center'}}>Cargando...</div>;
|
| 17 |
+
if (!currentUser) return <Navigate to="/login" replace />;
|
| 18 |
+
if (userRole === 'admin') return <Navigate to="/admin" replace />;
|
| 19 |
+
if (userRole === 'mesero') return <Navigate to="/pos" replace />;
|
| 20 |
+
return <Navigate to="/login" replace />; // Default
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function App() {
|
| 24 |
+
return (
|
| 25 |
+
<Router>
|
| 26 |
+
<AuthProvider>
|
| 27 |
+
<Routes>
|
| 28 |
+
<Route path="/" element={<RootRedirect />} />
|
| 29 |
+
<Route path="/login" element={<Login />} />
|
| 30 |
+
<Route path="/menu" element={<CustomerMenu />} />
|
| 31 |
+
|
| 32 |
+
{/* Rutas Privadas */}
|
| 33 |
+
<Route
|
| 34 |
+
path="/admin/*"
|
| 35 |
+
element={
|
| 36 |
+
<ProtectedRoute allowedRoles={['admin']}>
|
| 37 |
+
<AdminDashboard />
|
| 38 |
+
</ProtectedRoute>
|
| 39 |
+
}
|
| 40 |
+
/>
|
| 41 |
+
<Route
|
| 42 |
+
path="/pos/*"
|
| 43 |
+
element={
|
| 44 |
+
<ProtectedRoute allowedRoles={['admin', 'mesero']}>
|
| 45 |
+
<WaiterPOS />
|
| 46 |
+
</ProtectedRoute>
|
| 47 |
+
}
|
| 48 |
+
/>
|
| 49 |
+
<Route
|
| 50 |
+
path="/kitchen"
|
| 51 |
+
element={
|
| 52 |
+
<ProtectedRoute allowedRoles={['admin', 'mesero']}>
|
| 53 |
+
<KitchenView />
|
| 54 |
+
</ProtectedRoute>
|
| 55 |
+
}
|
| 56 |
+
/>
|
| 57 |
+
|
| 58 |
+
{/* Fallback */}
|
| 59 |
+
<Route path="*" element={<Navigate to="/" replace />} />
|
| 60 |
+
</Routes>
|
| 61 |
+
</AuthProvider>
|
| 62 |
+
</Router>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export default App;
|
frontend/src/assets/hero.png
ADDED
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/assets/vite.svg
ADDED
|
|
frontend/src/components/ProtectedRoute.jsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Navigate } from 'react-router-dom';
|
| 3 |
+
import { useAuth } from '../context/AuthContext';
|
| 4 |
+
|
| 5 |
+
export default function ProtectedRoute({ children, allowedRoles }) {
|
| 6 |
+
const { currentUser, userRole, loading } = useAuth();
|
| 7 |
+
|
| 8 |
+
if (loading) return <div className="app-container" style={{justifyContent:'center', alignItems:'center'}}>Cargando...</div>;
|
| 9 |
+
|
| 10 |
+
if (!currentUser) {
|
| 11 |
+
return <Navigate to="/login" replace />;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
if (allowedRoles && !allowedRoles.includes(userRole)) {
|
| 15 |
+
return <Navigate to="/unauthorized" replace />;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
return children;
|
| 19 |
+
}
|
frontend/src/components/admin/CRMManager.jsx
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { db } from '../../firebase/config';
|
| 3 |
+
import { ref, onValue } from 'firebase/database';
|
| 4 |
+
import { Users, Truck, Heart, Star, Phone, MapPin, History, Search } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
export default function CRMManager() {
|
| 7 |
+
const [customers, setCustomers] = useState([]);
|
| 8 |
+
const [deliveryOrders, setDeliveryOrders] = useState([]);
|
| 9 |
+
const [searchTerm, setSearchTerm] = useState('');
|
| 10 |
+
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
// Analytics for CRM (from Orders)
|
| 13 |
+
onValue(ref(db, 'orders'), (snapshot) => {
|
| 14 |
+
const data = snapshot.val();
|
| 15 |
+
if (data) {
|
| 16 |
+
const orders = Object.values(data);
|
| 17 |
+
const customerMap = {};
|
| 18 |
+
const deliveries = [];
|
| 19 |
+
|
| 20 |
+
orders.forEach(order => {
|
| 21 |
+
if (order.customerEmail) {
|
| 22 |
+
const email = order.customerEmail;
|
| 23 |
+
if (!customerMap[email]) {
|
| 24 |
+
customerMap[email] = {
|
| 25 |
+
email,
|
| 26 |
+
name: order.customerName || 'Cliente',
|
| 27 |
+
totalSpent: 0,
|
| 28 |
+
orderCount: 0,
|
| 29 |
+
lastOrder: 0
|
| 30 |
+
};
|
| 31 |
+
}
|
| 32 |
+
customerMap[email].totalSpent += order.total;
|
| 33 |
+
customerMap[email].orderCount += 1;
|
| 34 |
+
if (order.timestamp > customerMap[email].lastOrder) {
|
| 35 |
+
customerMap[email].lastOrder = order.timestamp;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
if (order.type === 'delivery') {
|
| 40 |
+
deliveries.push(order);
|
| 41 |
+
}
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
setCustomers(Object.values(customerMap).sort((a,b) => b.totalSpent - a.totalSpent));
|
| 45 |
+
setDeliveryOrders(deliveries.sort((a,b) => b.timestamp - a.timestamp));
|
| 46 |
+
}
|
| 47 |
+
});
|
| 48 |
+
}, []);
|
| 49 |
+
|
| 50 |
+
const filteredCustomers = customers.filter(c =>
|
| 51 |
+
c.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
| 52 |
+
c.name.toLowerCase().includes(searchTerm.toLowerCase())
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="animate-fade-in" style={{ padding: '0 1rem' }}>
|
| 57 |
+
<header style={{ marginBottom: '2.5rem' }}>
|
| 58 |
+
<h2 className="text-gradient" style={{ fontSize: '2rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 59 |
+
<Users size={28} /> CRM & Fidelización
|
| 60 |
+
</h2>
|
| 61 |
+
<p style={{ color: 'var(--text-muted)' }}>Gestión de clientes frecuentes y seguimiento de delivery</p>
|
| 62 |
+
</header>
|
| 63 |
+
|
| 64 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '2rem' }}>
|
| 65 |
+
|
| 66 |
+
{/* Customer List */}
|
| 67 |
+
<section className="glass-card">
|
| 68 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
| 69 |
+
<h3 style={sectionTitleStyle}><Heart size={18} /> Clientes Frecuentes</h3>
|
| 70 |
+
<div style={{ position: 'relative', width: '200px' }}>
|
| 71 |
+
<Search size={14} style={{ position: 'absolute', left: '10px', top: '12px', color: 'var(--text-muted)' }} />
|
| 72 |
+
<input
|
| 73 |
+
type="text" placeholder="Buscar..."
|
| 74 |
+
value={searchTerm} onChange={e => setSearchTerm(e.target.value)}
|
| 75 |
+
style={{ ...inputStyle, paddingLeft: '30px', padding: '0.4rem 0.5rem 0.4rem 2rem', fontSize: '0.8rem' }}
|
| 76 |
+
/>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxHeight: '500px', overflowY: 'auto' }}>
|
| 81 |
+
{filteredCustomers.map((c, i) => (
|
| 82 |
+
<div key={c.email} className="glass-panel" style={{ padding: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 83 |
+
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
| 84 |
+
<div style={{ width: '40px', height: '40px', background: 'rgba(0,0,0,0.03)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: '800', color: i < 3 ? 'var(--warning)' : 'var(--text-muted)' }}>
|
| 85 |
+
{i < 3 ? <Star size={18} fill="var(--warning)" /> : i + 1}
|
| 86 |
+
</div>
|
| 87 |
+
<div>
|
| 88 |
+
<div style={{ fontWeight: '700' }}>{c.name}</div>
|
| 89 |
+
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{c.email}</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
<div style={{ textAlign: 'right' }}>
|
| 93 |
+
<div style={{ color: 'var(--success)', fontWeight: '800' }}>${c.totalSpent.toFixed(2)}</div>
|
| 94 |
+
<div style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>{c.orderCount} visitas | {Math.floor(c.totalSpent / 10)} pts</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
))}
|
| 98 |
+
</div>
|
| 99 |
+
</section>
|
| 100 |
+
|
| 101 |
+
{/* Delivery Activity */}
|
| 102 |
+
<section className="glass-card">
|
| 103 |
+
<h3 style={sectionTitleStyle}><Truck size={18} /> Monitor de Delivery</h3>
|
| 104 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
| 105 |
+
{deliveryOrders.slice(0, 10).map(order => (
|
| 106 |
+
<div key={order.id} style={{ padding: '1rem', borderLeft: '3px solid var(--primary)', background: 'rgba(0,0,0,0.02)', borderRadius: '0 8px 8px 0' }}>
|
| 107 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
| 108 |
+
<span style={{ fontWeight: '700', fontSize: '0.9rem' }}>#{order.id.slice(-5)} - {order.customerName}</span>
|
| 109 |
+
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: '4px', background: 'rgba(255,90,95,0.1)', color: 'var(--primary)' }}>{order.status.toUpperCase()}</span>
|
| 110 |
+
</div>
|
| 111 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
| 112 |
+
<MapPin size={12} /> {order.address || 'Local / Para llevar'}
|
| 113 |
+
</div>
|
| 114 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
| 115 |
+
<History size={12} /> {new Date(order.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
))}
|
| 119 |
+
{deliveryOrders.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-muted)', paddingTop: '2rem' }}>No hay pedidos delivery recientes</p>}
|
| 120 |
+
</div>
|
| 121 |
+
</section>
|
| 122 |
+
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const inputStyle = { width: '100%', padding: '0.8rem', borderRadius: '8px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)', color: 'var(--text-main)', outline: 'none' };
|
| 129 |
+
const sectionTitleStyle = { fontSize: '1.2rem', fontWeight: '700', display: 'flex', alignItems: 'center', gap: '0.75rem' };
|
frontend/src/components/admin/DashboardOverview.jsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { db } from '../../firebase/config';
|
| 3 |
+
import { ref, onValue } from 'firebase/database';
|
| 4 |
+
import { TrendingUp, Users, ShoppingBag, AlertCircle } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
export default function DashboardOverview() {
|
| 7 |
+
const [stats, setStats] = useState({ ventasHoy: 0, mesasActivas: 0, stockBajo: 0 });
|
| 8 |
+
|
| 9 |
+
useEffect(() => {
|
| 10 |
+
// Aquí iría la escucha de Firebase para stats en tiempo real
|
| 11 |
+
setStats({
|
| 12 |
+
ventasHoy: 12500,
|
| 13 |
+
mesasActivas: 4,
|
| 14 |
+
stockBajo: 2
|
| 15 |
+
});
|
| 16 |
+
}, []);
|
| 17 |
+
|
| 18 |
+
const cards = [
|
| 19 |
+
{ title: 'Ventas de Hoy', value: `$${stats.ventasHoy.toLocaleString()}`, icon: <TrendingUp size={24} color="var(--success)" />, bg: 'rgba(32, 201, 151, 0.1)' },
|
| 20 |
+
{ title: 'Mesas Activas', value: stats.mesasActivas, icon: <Users size={24} color="var(--info)" />, bg: 'rgba(50, 173, 230, 0.1)' },
|
| 21 |
+
{ title: 'Ticket Promedio', value: '$275.50', icon: <ShoppingBag size={24} color="var(--primary)" />, bg: 'rgba(255, 90, 95, 0.1)' },
|
| 22 |
+
{ title: 'Utilidad Est.', value: '$4,200', icon: <AlertCircle size={24} color="var(--warning)" />, bg: 'rgba(245, 166, 35, 0.1)' }
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="animate-fade-in">
|
| 27 |
+
<h2 className="text-gradient" style={{ fontSize: '2rem', marginBottom: '1.5rem' }}>Resumen General</h2>
|
| 28 |
+
|
| 29 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1.5rem', marginBottom: '3rem' }}>
|
| 30 |
+
{cards.map((card, i) => (
|
| 31 |
+
<div key={i} className="glass-card" style={{ display: 'flex', alignItems: 'center', gap: '1.5rem' }}>
|
| 32 |
+
<div style={{ width: '56px', height: '56px', borderRadius: '50%', background: card.bg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
| 33 |
+
{card.icon}
|
| 34 |
+
</div>
|
| 35 |
+
<div>
|
| 36 |
+
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: '0.25rem' }}>{card.title}</p>
|
| 37 |
+
<h3 style={{ fontSize: '1.5rem', fontWeight: '700' }}>{card.value}</h3>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
))}
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
| 44 |
+
<div className="glass-card">
|
| 45 |
+
<h3 className="text-gradient" style={{ marginBottom: '1rem' }}>Saturación por Horario (Peak Hours)</h3>
|
| 46 |
+
<div style={{ height: '10px', width: '100%', background: 'rgba(0,0,0,0.05)', borderRadius: '10px', overflow: 'hidden', display: 'flex' }}>
|
| 47 |
+
<div style={{ width: '20%', background: 'var(--primary)', opacity: 0.3 }}></div>
|
| 48 |
+
<div style={{ width: '40%', background: 'var(--primary)' }}></div>
|
| 49 |
+
<div style={{ width: '15%', background: 'var(--primary)', opacity: 0.5 }}></div>
|
| 50 |
+
<div style={{ width: '25%', background: 'rgba(0,0,0,0.05)' }}></div>
|
| 51 |
+
</div>
|
| 52 |
+
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '1rem' }}>Punto máximo detectado: 14:00 - 15:30 (Almuerzo)</p>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div className="glass-card">
|
| 56 |
+
<h3 className="text-gradient" style={{ marginBottom: '1rem' }}>Alertas de Suministros (ERP)</h3>
|
| 57 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
| 58 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem' }}>
|
| 59 |
+
<span>Tomate Cherry (Kg)</span>
|
| 60 |
+
<span style={{ color: 'var(--danger)', fontWeight: '700' }}>1.2 (Bajo)</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem' }}>
|
| 63 |
+
<span>Aceite de Oliva (L)</span>
|
| 64 |
+
<span style={{ color: 'var(--warning)', fontWeight: '700' }}>3.0 (Crítico)</span>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
);
|
| 71 |
+
}
|
frontend/src/components/admin/FinanceManager.jsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { db } from '../../firebase/config';
|
| 3 |
+
import { ref, onValue, push, set } from 'firebase/database';
|
| 4 |
+
import { DollarSign, ArrowUpCircle, ArrowDownCircle, Save, Calendar, Clock, History } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
export default function FinanceManager() {
|
| 7 |
+
const [cashSessions, setCashSessions] = useState([]);
|
| 8 |
+
const [expenses, setExpenses] = useState([]);
|
| 9 |
+
const [activeSession, setActiveSession] = useState(null);
|
| 10 |
+
const [openingBalance, setOpeningBalance] = useState('');
|
| 11 |
+
const [expenseData, setExpenseData] = useState({ concept: '', amount: '', category: 'Varios' });
|
| 12 |
+
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
// Fetch sessions
|
| 15 |
+
onValue(ref(db, 'finance/sessions'), (snapshot) => {
|
| 16 |
+
const data = snapshot.val();
|
| 17 |
+
if (data) {
|
| 18 |
+
const list = Object.keys(data).map(id => ({ id, ...data[id] })).sort((a,b) => b.openedAt - a.openedAt);
|
| 19 |
+
setCashSessions(list);
|
| 20 |
+
const active = list.find(s => s.status === 'open');
|
| 21 |
+
setActiveSession(active || null);
|
| 22 |
+
}
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
// Fetch expenses
|
| 26 |
+
onValue(ref(db, 'finance/expenses'), (snapshot) => {
|
| 27 |
+
const data = snapshot.val();
|
| 28 |
+
setExpenses(data ? Object.keys(data).map(id => ({ id, ...data[id] })).sort((a,b) => b.timestamp - a.timestamp) : []);
|
| 29 |
+
});
|
| 30 |
+
}, []);
|
| 31 |
+
|
| 32 |
+
const openCash = async () => {
|
| 33 |
+
if (!openingBalance) return;
|
| 34 |
+
const newSessionRef = push(ref(db, 'finance/sessions'));
|
| 35 |
+
await set(newSessionRef, {
|
| 36 |
+
openedAt: Date.now(),
|
| 37 |
+
openingBalance: parseFloat(openingBalance),
|
| 38 |
+
status: 'open',
|
| 39 |
+
totalSales: 0,
|
| 40 |
+
totalExpenses: 0
|
| 41 |
+
});
|
| 42 |
+
setOpeningBalance('');
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const closeCash = async () => {
|
| 46 |
+
if (!activeSession) return;
|
| 47 |
+
if (window.confirm('¿Deseas cerrar el turno de caja actual?')) {
|
| 48 |
+
await set(ref(db, `finance/sessions/${activeSession.id}/status`), 'closed');
|
| 49 |
+
await set(ref(db, `finance/sessions/${activeSession.id}/closedAt`), Date.now());
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const addExpense = async (e) => {
|
| 54 |
+
e.preventDefault();
|
| 55 |
+
if (!expenseData.concept || !expenseData.amount) return;
|
| 56 |
+
const expenseRef = push(ref(db, 'finance/expenses'));
|
| 57 |
+
await set(expenseRef, {
|
| 58 |
+
...expenseData,
|
| 59 |
+
amount: parseFloat(expenseData.amount),
|
| 60 |
+
timestamp: Date.now(),
|
| 61 |
+
sessionId: activeSession?.id || 'none'
|
| 62 |
+
});
|
| 63 |
+
setExpenseData({ concept: '', amount: '', category: 'Varios' });
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<div className="animate-fade-in" style={{ padding: '0 1rem' }}>
|
| 68 |
+
<header style={{ marginBottom: '2.5rem' }}>
|
| 69 |
+
<h2 className="text-gradient" style={{ fontSize: '2rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 70 |
+
<DollarSign size={28} /> Control Financiero
|
| 71 |
+
</h2>
|
| 72 |
+
<p style={{ color: 'var(--text-muted)' }}>Arqueo de caja, egresos y flujo de efectivo semanal</p>
|
| 73 |
+
</header>
|
| 74 |
+
|
| 75 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', gap: '2rem' }}>
|
| 76 |
+
|
| 77 |
+
{/* Cash Arqueo Module */}
|
| 78 |
+
<div className="glass-card" style={{ border: activeSession ? '1px solid var(--success)' : '1px solid var(--border-subtle)' }}>
|
| 79 |
+
<h3 style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 80 |
+
<Clock size={20} /> Turno de Caja (Arqueo)
|
| 81 |
+
</h3>
|
| 82 |
+
|
| 83 |
+
{!activeSession ? (
|
| 84 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
| 85 |
+
<p style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>Caja cerrada actualmente. Ingresa el fondo de apertura para iniciar.</p>
|
| 86 |
+
<input
|
| 87 |
+
type="number"
|
| 88 |
+
placeholder="Monto de Apertura ($)"
|
| 89 |
+
value={openingBalance}
|
| 90 |
+
onChange={(e) => setOpeningBalance(e.target.value)}
|
| 91 |
+
style={inputStyle}
|
| 92 |
+
/>
|
| 93 |
+
<button onClick={openCash} className="btn-primary" style={{ padding: '0.8rem' }}>
|
| 94 |
+
Abrir Turno
|
| 95 |
+
</button>
|
| 96 |
+
</div>
|
| 97 |
+
) : (
|
| 98 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
| 99 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
| 100 |
+
<div style={statBoxStyle}>
|
| 101 |
+
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>Apertura</span>
|
| 102 |
+
<div style={{ fontSize: '1.2rem', fontWeight: '800' }}>${activeSession.openingBalance.toFixed(2)}</div>
|
| 103 |
+
</div>
|
| 104 |
+
<div style={statBoxStyle}>
|
| 105 |
+
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>Hora</span>
|
| 106 |
+
<div style={{ fontSize: '1rem', fontWeight: '600' }}>{new Date(activeSession.openedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
<div style={{ ...statBoxStyle, background: 'rgba(76,217,100,0.05)', borderColor: 'var(--success)' }}>
|
| 110 |
+
<span style={{ fontSize: '0.75rem', color: 'var(--success)' }}>Ventas Registradas</span>
|
| 111 |
+
<div style={{ fontSize: '1.5rem', fontWeight: '900', color: 'var(--success)' }}>$0.00</div>
|
| 112 |
+
<p style={{ fontSize: '0.7rem', opacity: 0.6, marginTop: '0.2rem', color: 'var(--text-muted)' }}>Sincronizado con POS en tiempo real</p>
|
| 113 |
+
</div>
|
| 114 |
+
<button onClick={closeCash} className="btn-glass" style={{ color: 'var(--primary)', borderColor: 'var(--primary)', padding: '0.8rem' }}>
|
| 115 |
+
Cerrar Turno / Corte
|
| 116 |
+
</button>
|
| 117 |
+
</div>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
{/* Expenses Module */}
|
| 122 |
+
<div className="glass-card">
|
| 123 |
+
<h3 style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 124 |
+
<ArrowDownCircle size={20} /> Registro de Egresos
|
| 125 |
+
</h3>
|
| 126 |
+
<form onSubmit={addExpense} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
| 127 |
+
<input type="text" placeholder="Concepto (ej. Pago luz, Propina extra)" value={expenseData.concept} onChange={e => setExpenseData({...expenseData, concept: e.target.value})} style={inputStyle} />
|
| 128 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
| 129 |
+
<input type="number" placeholder="Monto ($)" value={expenseData.amount} onChange={e => setExpenseData({...expenseData, amount: e.target.value})} style={inputStyle} />
|
| 130 |
+
<select value={expenseData.category} onChange={e => setExpenseData({...expenseData, category: e.target.value})} style={inputStyle}>
|
| 131 |
+
<option value="Varios">Varios</option>
|
| 132 |
+
<option value="Compras">Compras</option>
|
| 133 |
+
<option value="Servicios">Servicios</option>
|
| 134 |
+
<option value="Sueldos">Sueldos</option>
|
| 135 |
+
</select>
|
| 136 |
+
</div>
|
| 137 |
+
<button type="submit" className="btn-glass" style={{ padding: '0.8rem' }}>
|
| 138 |
+
<Plus size={18} /> Registrar Egreso
|
| 139 |
+
</button>
|
| 140 |
+
</form>
|
| 141 |
+
|
| 142 |
+
<div style={{ marginTop: '2rem', maxHeight: '200px', overflowY: 'auto' }}>
|
| 143 |
+
<h4 style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '1rem', borderBottom: '1px solid var(--border-subtle)', paddingBottom: '0.5rem' }}>Últimos Gastos</h4>
|
| 144 |
+
{expenses.map(exp => (
|
| 145 |
+
<div key={exp.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0', fontSize: '0.9rem' }}>
|
| 146 |
+
<span>{exp.concept}</span>
|
| 147 |
+
<span style={{ color: 'var(--primary)', fontWeight: '700' }}>-${exp.amount.toFixed(2)}</span>
|
| 148 |
+
</div>
|
| 149 |
+
))}
|
| 150 |
+
{expenses.length === 0 && <p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', textAlign: 'center' }}>No hay gastos registrados</p>}
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<div className="glass-panel" style={{ marginTop: '3rem', padding: '1.5rem' }}>
|
| 156 |
+
<h3 style={{ fontSize: '1.1rem', marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 157 |
+
<History size={18} /> Historial de Turnos Recientes
|
| 158 |
+
</h3>
|
| 159 |
+
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
| 160 |
+
<thead>
|
| 161 |
+
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border-subtle)', opacity: 0.6 }}>
|
| 162 |
+
<th style={{ padding: '1rem' }}>Fecha</th>
|
| 163 |
+
<th style={{ padding: '1rem' }}>Apertura</th>
|
| 164 |
+
<th style={{ padding: '1rem' }}>Cierre</th>
|
| 165 |
+
<th style={{ padding: '1rem' }}>Ventas</th>
|
| 166 |
+
<th style={{ padding: '1rem' }}>Estado</th>
|
| 167 |
+
</tr>
|
| 168 |
+
</thead>
|
| 169 |
+
<tbody>
|
| 170 |
+
{cashSessions.map(sess => (
|
| 171 |
+
<tr key={sess.id} style={{ borderBottom: '1px solid var(--border-subtle)' }}>
|
| 172 |
+
<td style={{ padding: '1rem' }}>{new Date(sess.openedAt).toLocaleDateString()}</td>
|
| 173 |
+
<td style={{ padding: '1rem' }}>${sess.openingBalance.toFixed(2)}</td>
|
| 174 |
+
<td style={{ padding: '1rem' }}>{sess.closedAt ? new Date(sess.closedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '-'}</td>
|
| 175 |
+
<td style={{ padding: '1rem' }}>${(sess.totalSales || 0).toFixed(2)}</td>
|
| 176 |
+
<td style={{ padding: '1rem' }}>
|
| 177 |
+
<span style={{ padding: '2px 8px', borderRadius: '4px', background: sess.status === 'open' ? 'rgba(76,217,100,0.1)' : 'rgba(0,0,0,0.05)', color: sess.status === 'open' ? 'var(--success)' : 'var(--text-muted)', fontSize: '0.75rem' }}>
|
| 178 |
+
{sess.status.toUpperCase()}
|
| 179 |
+
</span>
|
| 180 |
+
</td>
|
| 181 |
+
</tr>
|
| 182 |
+
))}
|
| 183 |
+
</tbody>
|
| 184 |
+
</table>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const inputStyle = { width: '100%', padding: '0.8rem', borderRadius: '8px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)', color: 'var(--text-main)', outline: 'none' };
|
| 191 |
+
const statBoxStyle = { padding: '1rem', borderRadius: '12px', background: 'rgba(0,0,0,0.02)', border: '1px solid var(--border-subtle)', display: 'flex', flexDirection: 'column', gap: '0.25rem' };
|
| 192 |
+
const Plus = ({ size }) => <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>;
|
frontend/src/components/admin/InventoryControl.jsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { db } from '../../firebase/config';
|
| 3 |
+
import { ref, onValue, push, set, remove, update } from 'firebase/database';
|
| 4 |
+
import { Plus, Trash2, Edit3, AlertTriangle, X, Save } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
export default function InventoryControl() {
|
| 7 |
+
const [items, setItems] = useState([]);
|
| 8 |
+
const [formData, setFormData] = useState({ name: '', quantity: '', unit: '', minStock: '' });
|
| 9 |
+
const [editingItem, setEditingItem] = useState(null);
|
| 10 |
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
const inventoryRef = ref(db, 'inventory');
|
| 14 |
+
onValue(inventoryRef, (snapshot) => {
|
| 15 |
+
const data = snapshot.val();
|
| 16 |
+
if (data) {
|
| 17 |
+
const productList = Object.keys(data).map(key => ({ id: key, ...data[key] }));
|
| 18 |
+
setItems(productList);
|
| 19 |
+
} else {
|
| 20 |
+
setItems([]);
|
| 21 |
+
}
|
| 22 |
+
});
|
| 23 |
+
}, []);
|
| 24 |
+
|
| 25 |
+
const handleAddItem = async (e) => {
|
| 26 |
+
e.preventDefault();
|
| 27 |
+
if (!formData.name || !formData.quantity) return;
|
| 28 |
+
|
| 29 |
+
const inventoryRef = ref(db, 'inventory');
|
| 30 |
+
const newItemRef = push(inventoryRef);
|
| 31 |
+
await set(newItemRef, {
|
| 32 |
+
name: formData.name,
|
| 33 |
+
quantity: Number(formData.quantity),
|
| 34 |
+
unit: formData.unit || 'Und',
|
| 35 |
+
minStock: Number(formData.minStock) || 10
|
| 36 |
+
});
|
| 37 |
+
setFormData({ name: '', quantity: '', unit: '', minStock: '' });
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const handleDelete = async (id) => {
|
| 41 |
+
if(window.confirm('¿Seguro que deseas eliminar este insumo?')){
|
| 42 |
+
await remove(ref(db, `inventory/${id}`));
|
| 43 |
+
}
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const openEdit = (item) => {
|
| 47 |
+
setEditingItem({ ...item });
|
| 48 |
+
setIsModalOpen(true);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const handleSaveEdit = async () => {
|
| 52 |
+
if (!editingItem.name) return;
|
| 53 |
+
const { id, ...updates } = editingItem;
|
| 54 |
+
await update(ref(db, `inventory/${id}`), {
|
| 55 |
+
...updates,
|
| 56 |
+
quantity: Number(updates.quantity),
|
| 57 |
+
minStock: Number(updates.minStock)
|
| 58 |
+
});
|
| 59 |
+
setEditingItem(null);
|
| 60 |
+
setIsModalOpen(false);
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const handleAddStock = async (id, currentQty) => {
|
| 64 |
+
await set(ref(db, `inventory/${id}/quantity`), currentQty + 10);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<div className="animate-fade-in" style={{ padding: '0 1rem' }}>
|
| 69 |
+
<h2 className="text-gradient" style={{ fontSize: '2rem', marginBottom: '1.5rem', fontWeight: '800' }}>Control de Stock</h2>
|
| 70 |
+
|
| 71 |
+
<div className="glass-card" style={{ marginBottom: '2rem', padding: '1.5rem' }}>
|
| 72 |
+
<h3 style={{ marginBottom: '1.25rem', fontSize: '1.1rem' }}>Registrar Nuevo Insumo</h3>
|
| 73 |
+
<form onSubmit={handleAddItem} style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
| 74 |
+
<div style={{ flex: 2, minWidth: '150px' }}>
|
| 75 |
+
<label style={labelStyle}>Nombre del Insumo</label>
|
| 76 |
+
<input type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} required style={inputStyle} placeholder="Tomate rojo" />
|
| 77 |
+
</div>
|
| 78 |
+
<div style={{ flex: 1, minWidth: '80px' }}>
|
| 79 |
+
<label style={labelStyle}>Cantidad</label>
|
| 80 |
+
<input type="number" value={formData.quantity} onChange={e => setFormData({...formData, quantity: e.target.value})} required style={inputStyle} placeholder="50" />
|
| 81 |
+
</div>
|
| 82 |
+
<div style={{ flex: 1, minWidth: '80px' }}>
|
| 83 |
+
<label style={labelStyle}>Unidad</label>
|
| 84 |
+
<input type="text" value={formData.unit} onChange={e => setFormData({...formData, unit: e.target.value})} style={inputStyle} placeholder="Kg, Lt, Und" />
|
| 85 |
+
</div>
|
| 86 |
+
<button type="submit" className="btn-primary" style={{ padding: '0.8rem 1.5rem' }}>
|
| 87 |
+
<Plus size={20} />
|
| 88 |
+
</button>
|
| 89 |
+
</form>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div className="glass-panel" style={{ overflow: 'hidden' }}>
|
| 93 |
+
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
|
| 94 |
+
<thead>
|
| 95 |
+
<tr style={{ borderBottom: '1px solid var(--border-subtle)', background: 'rgba(255,255,255,0.02)' }}>
|
| 96 |
+
<th style={{ padding: '1.25rem', color: 'var(--text-muted)', fontWeight: '500' }}>Insumo</th>
|
| 97 |
+
<th style={{ padding: '1.25rem', color: 'var(--text-muted)', fontWeight: '500' }}>Stock Actual</th>
|
| 98 |
+
<th style={{ padding: '1.25rem', color: 'var(--text-muted)', fontWeight: '500' }}>Estado</th>
|
| 99 |
+
<th style={{ padding: '1.25rem', color: 'var(--text-muted)', fontWeight: '500', textAlign: 'right' }}>Acciones</th>
|
| 100 |
+
</tr>
|
| 101 |
+
</thead>
|
| 102 |
+
<tbody>
|
| 103 |
+
{items.length === 0 ? (
|
| 104 |
+
<tr><td colSpan="4" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>Sin insumos registrados</td></tr>
|
| 105 |
+
) : (
|
| 106 |
+
items.map((item) => {
|
| 107 |
+
const isLow = item.quantity <= (item.minStock || 10);
|
| 108 |
+
return (
|
| 109 |
+
<tr key={item.id} style={{ borderBottom: '1px solid var(--border-subtle)', transition: 'background 0.2s' }} className="table-row-hover">
|
| 110 |
+
<td style={{ padding: '1.25rem', fontWeight: '600' }}>{item.name}</td>
|
| 111 |
+
<td style={{ padding: '1.25rem', fontWeight: '700', color: isLow ? 'var(--primary)' : 'var(--text-main)', fontSize: '1.1rem' }}>
|
| 112 |
+
{item.quantity} <span style={{ fontSize: '0.8rem', fontWeight: '400', color: 'var(--text-muted)' }}>{item.unit}</span>
|
| 113 |
+
</td>
|
| 114 |
+
<td style={{ padding: '1.25rem' }}>
|
| 115 |
+
{isLow ? (
|
| 116 |
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem', color: 'var(--primary)', fontSize: '0.8rem', background: 'rgba(255,107,107,0.1)', padding: '0.25rem 0.75rem', borderRadius: '12px', fontWeight: '600' }}>
|
| 117 |
+
<AlertTriangle size={14} /> RELLENAR
|
| 118 |
+
</span>
|
| 119 |
+
) : (
|
| 120 |
+
<span style={{ color: 'var(--success)', fontSize: '0.8rem', fontWeight: '600', background: 'rgba(76,217,100,0.1)', padding: '0.25rem 0.75rem', borderRadius: '12px' }}>ÓPTIMO</span>
|
| 121 |
+
)}
|
| 122 |
+
</td>
|
| 123 |
+
<td style={{ padding: '1.25rem', textAlign: 'right' }}>
|
| 124 |
+
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
| 125 |
+
<button onClick={() => handleAddStock(item.id, item.quantity)} title="Sumar 10" style={actionBtnStyle}>+10</button>
|
| 126 |
+
<button onClick={() => openEdit(item)} title="Editar" style={actionBtnStyle}><Edit3 size={16} /></button>
|
| 127 |
+
<button onClick={() => handleDelete(item.id)} title="Eliminar" style={{...actionBtnStyle, color: 'var(--primary)'}}><Trash2 size={16} /></button>
|
| 128 |
+
</div>
|
| 129 |
+
</td>
|
| 130 |
+
</tr>
|
| 131 |
+
)
|
| 132 |
+
})
|
| 133 |
+
)}
|
| 134 |
+
</tbody>
|
| 135 |
+
</table>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
{/* Modal de Edición */}
|
| 139 |
+
{isModalOpen && (
|
| 140 |
+
<div style={{ position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 9999, padding: '20px' }}>
|
| 141 |
+
<div className="glass-panel animate-slide-up" style={{ maxWidth: '450px', width: '100%', padding: '2.5rem' }}>
|
| 142 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
| 143 |
+
<h2 className="text-gradient" style={{ fontSize: '1.5rem' }}>Editar Insumo</h2>
|
| 144 |
+
<button onClick={() => setIsModalOpen(false)} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}><X size={24} /></button>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
| 148 |
+
<div>
|
| 149 |
+
<label style={labelStyle}>Nombre del Insumo</label>
|
| 150 |
+
<input type="text" value={editingItem.name} onChange={e => setEditingItem({...editingItem, name: e.target.value})} style={inputStyle} />
|
| 151 |
+
</div>
|
| 152 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
| 153 |
+
<div>
|
| 154 |
+
<label style={labelStyle}>Stock</label>
|
| 155 |
+
<input type="number" value={editingItem.quantity} onChange={e => setEditingItem({...editingItem, quantity: e.target.value})} style={inputStyle} />
|
| 156 |
+
</div>
|
| 157 |
+
<div>
|
| 158 |
+
<label style={labelStyle}>Min. Stock</label>
|
| 159 |
+
<input type="number" value={editingItem.minStock || 10} onChange={e => setEditingItem({...editingItem, minStock: e.target.value})} style={inputStyle} />
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
<div>
|
| 163 |
+
<label style={labelStyle}>Unidad</label>
|
| 164 |
+
<input type="text" value={editingItem.unit} onChange={e => setEditingItem({...editingItem, unit: e.target.value})} style={inputStyle} />
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<button onClick={handleSaveEdit} className="btn-primary" style={{ height: '50px', marginTop: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', justifyContent: 'center' }}>
|
| 168 |
+
<Save size={18} /> Guardar Cambios
|
| 169 |
+
</button>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
)}
|
| 174 |
+
</div>
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const inputStyle = {
|
| 179 |
+
width: '100%', padding: '0.8rem', borderRadius: '8px',
|
| 180 |
+
background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)',
|
| 181 |
+
color: 'var(--text-main)', outline: 'none'
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
const labelStyle = {
|
| 185 |
+
display: 'block', marginBottom: '0.5rem', fontSize: '0.85rem', color: 'var(--text-muted)'
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
const actionBtnStyle = {
|
| 189 |
+
width: '32px', height: '32px', borderRadius: '8px', border: '1px solid var(--border-subtle)',
|
| 190 |
+
background: 'rgba(0,0,0,0.02)', color: 'var(--text-main)', cursor: 'pointer',
|
| 191 |
+
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s', fontSize: '0.75rem', fontWeight: '700'
|
| 192 |
+
};
|
frontend/src/components/admin/MenuEditor.jsx
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { db, storage } from '../../firebase/config';
|
| 3 |
+
import { ref, onValue, push, set, remove, update } from 'firebase/database';
|
| 4 |
+
import { ref as sRef, uploadBytes, getDownloadURL } from 'firebase/storage';
|
| 5 |
+
import { Plus, Trash2, Edit3, X, Image as ImageIcon, Save, Palette, Upload, Loader2, ListTree, ChefHat } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export default function MenuEditor() {
|
| 8 |
+
const [products, setProducts] = useState([]);
|
| 9 |
+
const [inventory, setInventory] = useState([]);
|
| 10 |
+
const [formData, setFormData] = useState({
|
| 11 |
+
name: '', price: '', category: 'Principales', image: '',
|
| 12 |
+
variants: [], ingredients: []
|
| 13 |
+
});
|
| 14 |
+
const [editingItem, setEditingItem] = useState(null);
|
| 15 |
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 16 |
+
const [menuTheme, setMenuTheme] = useState('dark');
|
| 17 |
+
const [uploading, setUploading] = useState(false);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
onValue(ref(db, 'menu'), (snapshot) => {
|
| 21 |
+
const data = snapshot.val();
|
| 22 |
+
setProducts(data ? Object.keys(data).map(id => ({ id, ...data[id] })) : []);
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
onValue(ref(db, 'inventory'), (snapshot) => {
|
| 26 |
+
const data = snapshot.val();
|
| 27 |
+
setInventory(data ? Object.keys(data).map(id => ({ id, ...data[id] })) : []);
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
onValue(ref(db, 'config/menuTheme'), (snapshot) => {
|
| 31 |
+
if (snapshot.exists()) setMenuTheme(snapshot.val());
|
| 32 |
+
});
|
| 33 |
+
}, []);
|
| 34 |
+
|
| 35 |
+
const handleToggleTheme = async () => {
|
| 36 |
+
await set(ref(db, 'config/menuTheme'), menuTheme === 'dark' ? 'light' : 'dark');
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const handleImageUpload = async (e, isEditing = false) => {
|
| 40 |
+
const file = e.target.files[0];
|
| 41 |
+
if (!file || !file.type.startsWith('image/')) return;
|
| 42 |
+
setUploading(true);
|
| 43 |
+
try {
|
| 44 |
+
const storagePath = `menu/${Date.now()}_${file.name}`;
|
| 45 |
+
const fileRef = sRef(storage, storagePath);
|
| 46 |
+
await uploadBytes(fileRef, file);
|
| 47 |
+
const url = await getDownloadURL(fileRef);
|
| 48 |
+
if (isEditing) setEditingItem(prev => ({ ...prev, image: url }));
|
| 49 |
+
else setFormData(prev => ({ ...prev, image: url }));
|
| 50 |
+
} catch (error) { console.error(error); } finally { setUploading(false); }
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const handleAddProduct = async (e) => {
|
| 54 |
+
e.preventDefault();
|
| 55 |
+
if (!formData.name || !formData.price) return;
|
| 56 |
+
const newRef = push(ref(db, 'menu'));
|
| 57 |
+
await set(newRef, { ...formData, price: parseFloat(formData.price), active: true });
|
| 58 |
+
setFormData({ name: '', price: '', category: 'Principales', image: '', variants: [], ingredients: [] });
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
const handleDelete = async (id) => {
|
| 62 |
+
if(window.confirm('¿Eliminar producto?')) await remove(ref(db, `menu/${id}`));
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const addIngredientToProduct = (isEditing) => {
|
| 66 |
+
const newIng = { id: '', qty: 1 };
|
| 67 |
+
if (isEditing) setEditingItem({ ...editingItem, ingredients: [...(editingItem.ingredients || []), newIng] });
|
| 68 |
+
else setFormData({ ...formData, ingredients: [...formData.ingredients, newIng] });
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const handleSaveEdit = async () => {
|
| 72 |
+
const { id, ...updates } = editingItem;
|
| 73 |
+
await update(ref(db, `menu/${id}`), { ...updates, price: parseFloat(updates.price) });
|
| 74 |
+
setIsModalOpen(false);
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div className="animate-fade-in" style={{ padding: '0 1rem' }}>
|
| 79 |
+
<header style={{ marginBottom: '2.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 80 |
+
<div>
|
| 81 |
+
<h2 className="text-gradient" style={{ fontSize: '2rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 82 |
+
<ChefHat size={28} /> Carta & Recetas
|
| 83 |
+
</h2>
|
| 84 |
+
<p style={{ color: 'var(--text-muted)' }}>Gestión de productos y consumo de insumos</p>
|
| 85 |
+
</div>
|
| 86 |
+
<button onClick={handleToggleTheme} className="btn-glass" style={{ padding: '0.8rem 1.25rem' }}>
|
| 87 |
+
Tema QR: {menuTheme.toUpperCase()}
|
| 88 |
+
</button>
|
| 89 |
+
</header>
|
| 90 |
+
|
| 91 |
+
<section className="glass-card" style={{ marginBottom: '3rem', padding: '1.5rem' }}>
|
| 92 |
+
<h3 style={{ marginBottom: '1.5rem', fontSize: '1.1rem' }}>Agregar Platillo</h3>
|
| 93 |
+
<form onSubmit={handleAddProduct} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.25rem', alignItems: 'end' }}>
|
| 94 |
+
<div>
|
| 95 |
+
<label style={labelStyle}>Nombre</label>
|
| 96 |
+
<input type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} style={inputStyle} />
|
| 97 |
+
</div>
|
| 98 |
+
<div>
|
| 99 |
+
<label style={labelStyle}>Precio Base ($)</label>
|
| 100 |
+
<input type="number" value={formData.price} onChange={e => setFormData({...formData, price: e.target.value})} style={inputStyle} />
|
| 101 |
+
</div>
|
| 102 |
+
<div>
|
| 103 |
+
<label style={labelStyle}>Categoría</label>
|
| 104 |
+
<select value={formData.category} onChange={e => setFormData({...formData, category: e.target.value})} style={inputStyle}>
|
| 105 |
+
{['Entradas', 'Bebidas', 'Fuertes', 'Postres', 'Pizzas', 'Combos'].map(c => <option key={c} value={c}>{c}</option>)}
|
| 106 |
+
</select>
|
| 107 |
+
</div>
|
| 108 |
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
| 109 |
+
<button type="button" onClick={() => addIngredientToProduct(false)} className="btn-glass" style={{ flex: 1, padding: '0.8rem' }}>
|
| 110 |
+
<ListTree size={18} /> Insumos
|
| 111 |
+
</button>
|
| 112 |
+
<button type="submit" className="btn-primary" style={{ padding: '0.8rem 1.5rem', opacity: uploading ? 0.5 : 1 }}>
|
| 113 |
+
<Plus size={20} />
|
| 114 |
+
</button>
|
| 115 |
+
</div>
|
| 116 |
+
</form>
|
| 117 |
+
|
| 118 |
+
{formData.ingredients.length > 0 && (
|
| 119 |
+
<div style={{ marginTop: '1.5rem', display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
|
| 120 |
+
{formData.ingredients.map((ing, idx) => (
|
| 121 |
+
<div key={idx} className="glass-panel" style={{ padding: '0.5rem 1rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
| 122 |
+
<select
|
| 123 |
+
value={ing.id}
|
| 124 |
+
onChange={e => {
|
| 125 |
+
const newIngs = [...formData.ingredients];
|
| 126 |
+
newIngs[idx].id = e.target.value;
|
| 127 |
+
setFormData({...formData, ingredients: newIngs});
|
| 128 |
+
}}
|
| 129 |
+
style={{...inputStyle, padding: '0.4rem', width: 'auto'}}
|
| 130 |
+
>
|
| 131 |
+
<option value="">Seleccionar Insumo</option>
|
| 132 |
+
{inventory.map(i => <option key={i.id} value={i.id}>{i.name}</option>)}
|
| 133 |
+
</select>
|
| 134 |
+
<input
|
| 135 |
+
type="number"
|
| 136 |
+
value={ing.qty}
|
| 137 |
+
onChange={e => {
|
| 138 |
+
const newIngs = [...formData.ingredients];
|
| 139 |
+
newIngs[idx].qty = parseFloat(e.target.value);
|
| 140 |
+
setFormData({...formData, ingredients: newIngs});
|
| 141 |
+
}}
|
| 142 |
+
style={{...inputStyle, padding: '0.4rem', width: '60px', background: 'rgba(0,0,0,0.03)'}}
|
| 143 |
+
/>
|
| 144 |
+
<button type="button" onClick={() => setFormData({...formData, ingredients: formData.ingredients.filter((_, i) => i !== idx)})} style={{color: 'var(--primary)', background: 'none', border: 'none'}}><X size={16}/></button>
|
| 145 |
+
</div>
|
| 146 |
+
))}
|
| 147 |
+
</div>
|
| 148 |
+
)}
|
| 149 |
+
</section>
|
| 150 |
+
|
| 151 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1.5rem' }}>
|
| 152 |
+
{products.map(item => (
|
| 153 |
+
<div key={item.id} className="glass-card" style={{ padding: '0', overflow: 'hidden' }}>
|
| 154 |
+
<div style={{ position: 'relative', height: '160px', background: 'rgba(255,255,255,0.05)' }}>
|
| 155 |
+
{item.image && <img src={item.image} alt={item.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
|
| 156 |
+
<div style={{ position: 'absolute', top: '10px', right: '10px', display: 'flex', gap: '5px' }}>
|
| 157 |
+
<button onClick={() => { setEditingItem({...item}); setIsModalOpen(true); }} style={actionBtnStyle}><Edit3 size={16} /></button>
|
| 158 |
+
<button onClick={() => handleDelete(item.id)} style={{...actionBtnStyle, color: 'var(--primary)'}}><Trash2 size={16} /></button>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
<div style={{ padding: '1.25rem' }}>
|
| 162 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
| 163 |
+
<h4 style={{ fontWeight: '700' }}>{item.name}</h4>
|
| 164 |
+
<span style={{ color: 'var(--success)', fontWeight: '800' }}>${item.price}</span>
|
| 165 |
+
</div>
|
| 166 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 167 |
+
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', background: 'rgba(0,0,0,0.03)', padding: '2px 8px', borderRadius: '4px' }}>{item.category}</span>
|
| 168 |
+
{item.ingredients?.length > 0 && <span title="Tiene receta vinculada" style={{ color: 'var(--primary)' }}><ChefHat size={14} /></span>}
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
))}
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
{isModalOpen && editingItem && (
|
| 176 |
+
<div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', background: 'rgba(0,0,0,0.85)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000, padding: '20px' }}>
|
| 177 |
+
<div className="glass-panel animate-scale-in" style={{ maxWidth: '600px', width: '100%', padding: '2rem', maxHeight: '90vh', overflowY: 'auto' }}>
|
| 178 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2rem' }}>
|
| 179 |
+
<h2 className="text-gradient">Editar: {editingItem.name}</h2>
|
| 180 |
+
<button onClick={() => setIsModalOpen(false)} style={{ background: 'none', border: 'none', color: 'var(--text-muted)' }}><X size={24} /></button>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
| 184 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
| 185 |
+
<div><label style={labelStyle}>Nombre</label><input type="text" value={editingItem.name} onChange={e => setEditingItem({...editingItem, name: e.target.value})} style={inputStyle} /></div>
|
| 186 |
+
<div><label style={labelStyle}>Precio</label><input type="number" value={editingItem.price} onChange={e => setEditingItem({...editingItem, price: e.target.value})} style={inputStyle} /></div>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
<div>
|
| 190 |
+
<label style={labelStyle}>Receta (Consumo de insumos por venta)</label>
|
| 191 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
| 192 |
+
{(editingItem.ingredients || []).map((ing, i) => (
|
| 193 |
+
<div key={i} style={{ display: 'flex', gap: '0.5rem' }}>
|
| 194 |
+
<select value={ing.id} onChange={e => {
|
| 195 |
+
const ings = [...editingItem.ingredients];
|
| 196 |
+
ings[i].id = e.target.value;
|
| 197 |
+
setEditingItem({...editingItem, ingredients: ings});
|
| 198 |
+
}} style={{...inputStyle, flex: 2}}>
|
| 199 |
+
<option value="">Seleccionar Insumo</option>
|
| 200 |
+
{inventory.map(inv => <option key={inv.id} value={inv.id}>{inv.name}</option>)}
|
| 201 |
+
</select>
|
| 202 |
+
<input type="number" value={ing.qty} onChange={e => {
|
| 203 |
+
const ings = [...editingItem.ingredients];
|
| 204 |
+
ings[i].qty = parseFloat(e.target.value);
|
| 205 |
+
setEditingItem({...editingItem, ingredients: ings});
|
| 206 |
+
}} style={{...inputStyle, flex: 1}} />
|
| 207 |
+
<button onClick={() => setEditingItem({...editingItem, ingredients: editingItem.ingredients.filter((_, idx) => idx !== i)})} style={{color: 'var(--primary)'}}><Trash2 size={18} /></button>
|
| 208 |
+
</div>
|
| 209 |
+
))}
|
| 210 |
+
<button onClick={() => addIngredientToProduct(true)} className="btn-glass" style={{ padding: '0.5rem', fontSize: '0.8rem' }}><Plus size={14} /> Añadir Insumo</button>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
|
| 215 |
+
<label style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '0.5rem', background: 'var(--primary)', padding: '0.8rem', borderRadius: '8px', cursor: 'pointer', justifyContent: 'center' }}>
|
| 216 |
+
<Upload size={18} /> {uploading ? 'Subiendo...' : 'Cambiar Imagen'}
|
| 217 |
+
<input type="file" hidden onChange={e => handleImageUpload(e, true)} accept="image/*" />
|
| 218 |
+
</label>
|
| 219 |
+
<button onClick={handleSaveEdit} className="btn-primary" style={{ flex: 1, padding: '0.8rem' }}><Save size={18} /> Guardar</button>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
)}
|
| 225 |
+
</div>
|
| 226 |
+
);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
const inputStyle = { width: '100%', padding: '0.8rem', borderRadius: '8px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)', color: 'var(--text-main)', outline: 'none' };
|
| 230 |
+
const labelStyle = { display: 'block', fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.4rem' };
|
| 231 |
+
const actionBtnStyle = { width: '32px', height: '32px', borderRadius: '8px', border: 'none', background: 'rgba(255,255,255,0.9)', color: 'var(--text-main)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' };
|
frontend/src/components/admin/Reports.jsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { db } from '../../firebase/config';
|
| 3 |
+
import { ref, onValue } from 'firebase/database';
|
| 4 |
+
import {
|
| 5 |
+
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
| 6 |
+
LineElement, Title, Tooltip, Legend, BarElement
|
| 7 |
+
} from 'chart.js';
|
| 8 |
+
import { Line, Bar } from 'react-chartjs-2';
|
| 9 |
+
import { TrendingUp, Package, Users as UsersIcon, Clock, Award, BarChart3 } from 'lucide-react';
|
| 10 |
+
|
| 11 |
+
ChartJS.register(
|
| 12 |
+
CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, BarElement
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
export default function Reports() {
|
| 16 |
+
const [salesData, setSalesData] = useState([0, 0, 0, 0, 0, 0, 0]);
|
| 17 |
+
const [hourlyData, setHourlyData] = useState(new Array(24).fill(0));
|
| 18 |
+
const [waiterPerformance, setWaiterPerformance] = useState({ labels: [], data: [] });
|
| 19 |
+
const [topProducts, setTopProducts] = useState({ labels: [], data: [] });
|
| 20 |
+
const [stats, setStats] = useState({ totalSales: 0, orderCount: 0, avgTicket: 0 });
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
onValue(ref(db, 'orders'), (snapshot) => {
|
| 24 |
+
const data = snapshot.val();
|
| 25 |
+
if (data) {
|
| 26 |
+
const orders = Object.values(data).filter(o => o.status === 'completed');
|
| 27 |
+
const weeklySales = [0, 0, 0, 0, 0, 0, 0];
|
| 28 |
+
const hourSales = new Array(24).fill(0);
|
| 29 |
+
const productCounts = {};
|
| 30 |
+
const waiterSales = {};
|
| 31 |
+
let total = 0;
|
| 32 |
+
|
| 33 |
+
orders.forEach(order => {
|
| 34 |
+
total += order.total;
|
| 35 |
+
|
| 36 |
+
const date = new Date(order.timestamp);
|
| 37 |
+
// Weekly
|
| 38 |
+
const day = date.getDay();
|
| 39 |
+
const dayIdx = (day + 6) % 7;
|
| 40 |
+
weeklySales[dayIdx] += order.total;
|
| 41 |
+
|
| 42 |
+
// Hourly
|
| 43 |
+
const hour = date.getHours();
|
| 44 |
+
hourSales[hour] += 1; // Count orders per hour for peak density
|
| 45 |
+
|
| 46 |
+
// Waiters
|
| 47 |
+
const waiter = order.waiter || 'Sistema/Admin';
|
| 48 |
+
waiterSales[waiter] = (waiterSales[waiter] || 0) + order.total;
|
| 49 |
+
|
| 50 |
+
// Products
|
| 51 |
+
order.items.forEach(item => {
|
| 52 |
+
productCounts[item.name] = (productCounts[item.name] || 0) + item.qty;
|
| 53 |
+
});
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
setSalesData(weeklySales);
|
| 57 |
+
setHourlyData(hourSales);
|
| 58 |
+
setStats({
|
| 59 |
+
totalSales: total,
|
| 60 |
+
orderCount: orders.length,
|
| 61 |
+
avgTicket: total / (orders.length || 1)
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
const sortedProducts = Object.entries(productCounts)
|
| 65 |
+
.sort((a,b) => b[1] - a[1])
|
| 66 |
+
.slice(0, 5);
|
| 67 |
+
setTopProducts({
|
| 68 |
+
labels: sortedProducts.map(p => p[0]),
|
| 69 |
+
data: sortedProducts.map(p => p[1])
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
const sortedWaiters = Object.entries(waiterSales)
|
| 73 |
+
.sort((a,b) => b[1] - a[1]);
|
| 74 |
+
setWaiterPerformance({
|
| 75 |
+
labels: sortedWaiters.map(w => w[0].split('@')[0]),
|
| 76 |
+
data: sortedWaiters.map(w => w[1])
|
| 77 |
+
});
|
| 78 |
+
}
|
| 79 |
+
});
|
| 80 |
+
}, []);
|
| 81 |
+
|
| 82 |
+
const chartOptions = {
|
| 83 |
+
responsive: true,
|
| 84 |
+
maintainAspectRatio: false,
|
| 85 |
+
plugins: { legend: { labels: { color: '#9595a8', font: { size: 10 } } } },
|
| 86 |
+
scales: {
|
| 87 |
+
x: { ticks: { color: '#9595a8', font: { size: 10 } }, grid: { display: false } },
|
| 88 |
+
y: { ticks: { color: '#9595a8', font: { size: 10 } }, grid: { color: 'rgba(0,0,0,0.05)' } }
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
return (
|
| 93 |
+
<div className="animate-fade-in" style={{ paddingBottom: '3rem' }}>
|
| 94 |
+
<header style={{ marginBottom: '2.5rem' }}>
|
| 95 |
+
<h2 className="text-gradient" style={{ fontSize: '2rem', fontWeight: '800' }}>RESTO Analytics PLUS</h2>
|
| 96 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1.5rem', marginTop: '1.5rem' }}>
|
| 97 |
+
<StatCard title="Ventas Totales" value={`$${stats.totalSales.toFixed(2)}`} icon={<TrendingUp size={18}/>} color="var(--primary)" />
|
| 98 |
+
<StatCard title="Pedidos" value={stats.orderCount} icon={<Package size={18}/>} color="var(--success)" />
|
| 99 |
+
<StatCard title="Ticket Promedio" value={`$${stats.avgTicket.toFixed(2)}`} icon={<BarChart3 size={18}/>} color="#4dabf7" />
|
| 100 |
+
</div>
|
| 101 |
+
</header>
|
| 102 |
+
|
| 103 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(450px, 1fr))', gap: '2rem' }}>
|
| 104 |
+
{/* Ventas Semanales */}
|
| 105 |
+
<ChartCard title="Flujo de Ventas Semanal" icon={<Calendar size={18}/>}>
|
| 106 |
+
<Line data={{
|
| 107 |
+
labels: ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'],
|
| 108 |
+
datasets: [{ label: 'Ventas ($)', data: salesData, borderColor: 'var(--primary)', backgroundColor: 'rgba(255,90,95,0.1)', fill: true, tension: 0.4 }]
|
| 109 |
+
}} options={chartOptions} />
|
| 110 |
+
</ChartCard>
|
| 111 |
+
|
| 112 |
+
{/* Rendimiento Meseros */}
|
| 113 |
+
<ChartCard title="Rendimiento por Mesero" icon={<Award size={18}/>}>
|
| 114 |
+
<Bar data={{
|
| 115 |
+
labels: waiterPerformance.labels,
|
| 116 |
+
datasets: [{ label: 'Ventas Totales ($)', data: waiterPerformance.data, backgroundColor: 'rgba(76,217,100,0.6)', borderRadius: 6 }]
|
| 117 |
+
}} options={chartOptions} />
|
| 118 |
+
</ChartCard>
|
| 119 |
+
|
| 120 |
+
{/* Horas Pico */}
|
| 121 |
+
<ChartCard title="Horas Pico (Densidad de Pedidos)" icon={<Clock size={18}/>}>
|
| 122 |
+
<Line data={{
|
| 123 |
+
labels: Array.from({length: 24}, (_, i) => `${i}:00`),
|
| 124 |
+
datasets: [{ label: 'Pedidos', data: hourlyData, borderColor: '#4dabf7', backgroundColor: 'rgba(77,171,247,0.1)', fill: true, tension: 0.4 }]
|
| 125 |
+
}} options={chartOptions} />
|
| 126 |
+
</ChartCard>
|
| 127 |
+
|
| 128 |
+
{/* Top Productos */}
|
| 129 |
+
<ChartCard title="Productos Estrella" icon={<Package size={18}/>}>
|
| 130 |
+
<Bar data={{
|
| 131 |
+
labels: topProducts.labels,
|
| 132 |
+
datasets: [{ label: 'Unidades', data: topProducts.data, backgroundColor: 'rgba(0,0,0,0.03)', borderColor: 'var(--border-subtle)', borderWidth: 1, borderRadius: 6 }]
|
| 133 |
+
}} options={chartOptions} />
|
| 134 |
+
</ChartCard>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function StatCard({ title, value, icon, color }) {
|
| 141 |
+
return (
|
| 142 |
+
<div className="glass-card" style={{ padding: '1.5rem' }}>
|
| 143 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '0.75rem' }}>
|
| 144 |
+
{title} <span style={{ color }}>{icon}</span>
|
| 145 |
+
</div>
|
| 146 |
+
<div style={{ fontSize: '1.75rem', fontWeight: '900' }}>{value}</div>
|
| 147 |
+
</div>
|
| 148 |
+
);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function ChartCard({ title, icon, children }) {
|
| 152 |
+
return (
|
| 153 |
+
<div className="glass-card" style={{ height: '380px', display: 'flex', flexDirection: 'column', padding: '1.5rem' }}>
|
| 154 |
+
<h3 style={{ marginBottom: '1.5rem', fontSize: '1.1rem', fontWeight: '700', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 155 |
+
{icon} {title}
|
| 156 |
+
</h3>
|
| 157 |
+
<div style={{ flex: 1, position: 'relative' }}>
|
| 158 |
+
{children}
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
);
|
| 162 |
+
}
|
frontend/src/components/admin/SettingsPlus.jsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { db } from '../../firebase/config';
|
| 3 |
+
import { ref, onValue, set, update } from 'firebase/database';
|
| 4 |
+
import { Settings, Percent, DollarSign, Store, Globe, Bell, Save } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
export default function SettingsPlus() {
|
| 7 |
+
const [settings, setSettings] = useState({
|
| 8 |
+
restaurantName: 'Resto OS Premium',
|
| 9 |
+
currency: 'USD',
|
| 10 |
+
taxRate: 16,
|
| 11 |
+
serviceCharge: 10,
|
| 12 |
+
language: 'es',
|
| 13 |
+
timezone: 'America/Mexico_City'
|
| 14 |
+
});
|
| 15 |
+
const [saving, setSaving] = useState(false);
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
onValue(ref(db, 'config/settings'), (snapshot) => {
|
| 19 |
+
if (snapshot.exists()) setSettings(snapshot.val());
|
| 20 |
+
});
|
| 21 |
+
}, []);
|
| 22 |
+
|
| 23 |
+
const handleSave = async () => {
|
| 24 |
+
setSaving(true);
|
| 25 |
+
await set(ref(db, 'config/settings'), settings);
|
| 26 |
+
setTimeout(() => setSaving(false), 800);
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<div className="animate-fade-in" style={{ padding: '0 1rem' }}>
|
| 31 |
+
<header style={{ marginBottom: '2.5rem' }}>
|
| 32 |
+
<h2 className="text-gradient" style={{ fontSize: '2rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 33 |
+
<Settings size={28} /> Configuración Global
|
| 34 |
+
</h2>
|
| 35 |
+
<p style={{ color: 'var(--text-muted)' }}>Ajustes generales del sistema ERP</p>
|
| 36 |
+
</header>
|
| 37 |
+
|
| 38 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem' }}>
|
| 39 |
+
|
| 40 |
+
<div className="glass-card">
|
| 41 |
+
<h3 style={sectionTitleStyle}><Store size={18} /> Información Local</h3>
|
| 42 |
+
<div style={formGrid}>
|
| 43 |
+
<div style={inputGroup}>
|
| 44 |
+
<label style={labelStyle}>Nombre del Establecimiento</label>
|
| 45 |
+
<input type="text" value={settings.restaurantName} onChange={e => setSettings({...settings, restaurantName: e.target.value})} style={inputStyle} />
|
| 46 |
+
</div>
|
| 47 |
+
<div style={inputGroup}>
|
| 48 |
+
<label style={labelStyle}>Moneda Principal</label>
|
| 49 |
+
<select value={settings.currency} onChange={e => setSettings({...settings, currency: e.target.value})} style={inputStyle}>
|
| 50 |
+
<option value="USD">Dólar ($ USD)</option>
|
| 51 |
+
<option value="MXN">Peso ($ MXN)</option>
|
| 52 |
+
<option value="EUR">Euro (€ EUR)</option>
|
| 53 |
+
<option value="COP">Peso ($ COP)</option>
|
| 54 |
+
</select>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div className="glass-card">
|
| 60 |
+
<h3 style={sectionTitleStyle}><Percent size={18} /> Impuestos y Cargos</h3>
|
| 61 |
+
<div style={formGrid}>
|
| 62 |
+
<div style={inputGroup}>
|
| 63 |
+
<label style={labelStyle}>Impuesto (IVA/VAT) %</label>
|
| 64 |
+
<input type="number" value={settings.taxRate} onChange={e => setSettings({...settings, taxRate: parseFloat(e.target.value)})} style={inputStyle} />
|
| 65 |
+
</div>
|
| 66 |
+
<div style={inputGroup}>
|
| 67 |
+
<label style={labelStyle}>Servicio Sugerido %</label>
|
| 68 |
+
<input type="number" value={settings.serviceCharge} onChange={e => setSettings({...settings, serviceCharge: parseFloat(e.target.value)})} style={inputStyle} />
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div className="glass-card">
|
| 74 |
+
<h3 style={sectionTitleStyle}><Globe size={18} /> Idioma y Región</h3>
|
| 75 |
+
<div style={formGrid}>
|
| 76 |
+
<div style={inputGroup}>
|
| 77 |
+
<label style={labelStyle}>Idioma Interfaz</label>
|
| 78 |
+
<select value={settings.language} onChange={e => setSettings({...settings, language: e.target.value})} style={inputStyle}>
|
| 79 |
+
<option value="es">Español</option>
|
| 80 |
+
<option value="en">English</option>
|
| 81 |
+
</select>
|
| 82 |
+
</div>
|
| 83 |
+
<div style={inputGroup}>
|
| 84 |
+
<label style={labelStyle}>Zona Horaria</label>
|
| 85 |
+
<select value={settings.timezone} onChange={e => setSettings({...settings, timezone: e.target.value})} style={inputStyle}>
|
| 86 |
+
<option value="America/Mexico_City">Mexico City (CST)</option>
|
| 87 |
+
<option value="America/Bogota">Bogotá (EST)</option>
|
| 88 |
+
<option value="Europe/Madrid">Madrid (CET)</option>
|
| 89 |
+
</select>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div className="glass-card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', border: '1px dashed var(--primary)' }}>
|
| 95 |
+
<button
|
| 96 |
+
onClick={handleSave}
|
| 97 |
+
className="btn-primary"
|
| 98 |
+
style={{ width: '200px', height: '55px', fontSize: '1.1rem' }}
|
| 99 |
+
disabled={saving}
|
| 100 |
+
>
|
| 101 |
+
{saving ? 'Guardando...' : <span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Save size={20}/> Guardar Cambios</span>}
|
| 102 |
+
</button>
|
| 103 |
+
<p style={{ marginTop: '1rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>Los cambios se aplicarán a todos los terminales.</p>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const inputStyle = { width: '100%', padding: '0.8rem', borderRadius: '8px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)', color: 'var(--text-main)', outline: 'none' };
|
| 112 |
+
const labelStyle = { display: 'block', fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.5rem' };
|
| 113 |
+
const sectionTitleStyle = { marginBottom: '1.5rem', fontSize: '1.1rem', fontWeight: '700', display: 'flex', alignItems: 'center', gap: '0.75rem' };
|
| 114 |
+
const formGrid = { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' };
|
| 115 |
+
const inputGroup = { display: 'flex', flexDirection: 'column' };
|
frontend/src/components/admin/TableManager.jsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { db } from '../../firebase/config';
|
| 3 |
+
import { ref, onValue, set, update, push, remove } from 'firebase/database';
|
| 4 |
+
import { Layout, Plus, Trash2, Map as MapIcon, Grid, Save, MousePointer2 } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
export default function TableManager() {
|
| 7 |
+
const [tables, setTables] = useState([]);
|
| 8 |
+
const [newTable, setNewTable] = useState({ number: '', capacity: 4 });
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
onValue(ref(db, 'config/tables'), (snapshot) => {
|
| 12 |
+
const data = snapshot.val();
|
| 13 |
+
if (data) {
|
| 14 |
+
setTables(Object.keys(data).map(id => ({ id, ...data[id] })).sort((a,b) => a.number - b.number));
|
| 15 |
+
} else {
|
| 16 |
+
setTables([]);
|
| 17 |
+
}
|
| 18 |
+
});
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
const addTable = async (e) => {
|
| 22 |
+
e.preventDefault();
|
| 23 |
+
if (!newTable.number) return;
|
| 24 |
+
const tableRef = push(ref(db, 'config/tables'));
|
| 25 |
+
await set(tableRef, {
|
| 26 |
+
number: parseInt(newTable.number),
|
| 27 |
+
capacity: parseInt(newTable.capacity),
|
| 28 |
+
status: 'available',
|
| 29 |
+
x: 0,
|
| 30 |
+
y: 0
|
| 31 |
+
});
|
| 32 |
+
setNewTable({ number: '', capacity: 4 });
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const deleteTable = async (id) => {
|
| 36 |
+
if (window.confirm('¿Eliminar esta mesa del mapa?')) {
|
| 37 |
+
await remove(ref(db, `config/tables/${id}`));
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="animate-fade-in" style={{ padding: '0 1rem' }}>
|
| 43 |
+
<header style={{ marginBottom: '2.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 44 |
+
<div>
|
| 45 |
+
<h2 className="text-gradient" style={{ fontSize: '2rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 46 |
+
<MapIcon size={28} /> Diseño de Salón
|
| 47 |
+
</h2>
|
| 48 |
+
<p style={{ color: 'var(--text-muted)' }}>Configura la distribución física de tu restaurante</p>
|
| 49 |
+
</div>
|
| 50 |
+
</header>
|
| 51 |
+
|
| 52 |
+
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '2rem' }}>
|
| 53 |
+
|
| 54 |
+
{/* Sidebar: Add Tables */}
|
| 55 |
+
<aside className="glass-card" style={{ alignSelf: 'start' }}>
|
| 56 |
+
<h3 style={{ marginBottom: '1.5rem', fontSize: '1.1rem' }}>Añadir Mesas</h3>
|
| 57 |
+
<form onSubmit={addTable} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
| 58 |
+
<div>
|
| 59 |
+
<label style={labelStyle}>Número de Mesa</label>
|
| 60 |
+
<input type="number" value={newTable.number} onChange={e => setNewTable({...newTable, number: e.target.value})} style={inputStyle} placeholder="Ej. 10" />
|
| 61 |
+
</div>
|
| 62 |
+
<div>
|
| 63 |
+
<label style={labelStyle}>Capacidad (Personas)</label>
|
| 64 |
+
<input type="number" value={newTable.capacity} onChange={e => setNewTable({...newTable, capacity: e.target.value})} style={inputStyle} placeholder="Ej. 4" />
|
| 65 |
+
</div>
|
| 66 |
+
<button type="submit" className="btn-primary" style={{ padding: '0.8rem' }}>
|
| 67 |
+
<Plus size={18} /> Crear Mesa
|
| 68 |
+
</button>
|
| 69 |
+
</form>
|
| 70 |
+
|
| 71 |
+
<div style={{ marginTop: '2.5rem' }}>
|
| 72 |
+
<h4 style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '1rem', borderTop: '1px solid var(--border-subtle)', paddingTop: '1.5rem' }}>Listado</h4>
|
| 73 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', maxHeight: '300px', overflowY: 'auto' }}>
|
| 74 |
+
{tables.map(t => (
|
| 75 |
+
<div key={t.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.6rem 1rem', background: 'rgba(0,0,0,0.03)', borderRadius: '8px', alignItems: 'center' }}>
|
| 76 |
+
<span style={{ fontWeight: '700' }}>Mesa {t.number}</span>
|
| 77 |
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
| 78 |
+
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{t.capacity} px</span>
|
| 79 |
+
<button onClick={() => deleteTable(t.id)} style={{ color: 'var(--primary)', background: 'none', border: 'none' }}><Trash2 size={14} /></button>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
))}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
</aside>
|
| 86 |
+
|
| 87 |
+
{/* Visual Map Area */}
|
| 88 |
+
<section className="glass-panel" style={{ height: '600px', position: 'relative', overflow: 'hidden', background: 'var(--bg-card)', backgroundSize: '30px 30px', border: '1px solid var(--border-subtle)', backgroundImage: 'radial-gradient(circle, rgba(0,0,0,0.05) 1px, transparent 1px)' }}>
|
| 89 |
+
<div style={{ position: 'absolute', top: '1.5rem', left: '1.5rem', display: 'flex', gap: '1.5rem', fontSize: '0.75rem' }}>
|
| 90 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: 12, height: 12, background: 'var(--success)', borderRadius: '2px' }} /> Libre</div>
|
| 91 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: 12, height: 12, background: 'var(--primary)', borderRadius: '2px' }} /> Ocupado</div>
|
| 92 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: 12, height: 12, background: '#fcc419', borderRadius: '2px' }} /> Reservado</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<div style={{ width: '100%', height: '100%', padding: '5rem 3rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: '3rem' }}>
|
| 96 |
+
{tables.map(t => {
|
| 97 |
+
const statusColor = t.status === 'occupied' ? 'var(--primary)' : t.status === 'reserved' ? '#fcc419' : 'var(--success)';
|
| 98 |
+
return (
|
| 99 |
+
<div
|
| 100 |
+
key={t.id}
|
| 101 |
+
onClick={async () => {
|
| 102 |
+
const nextStatus = t.status === 'available' ? 'occupied' : t.status === 'occupied' ? 'reserved' : 'available';
|
| 103 |
+
await update(ref(db, `config/tables/${t.id}`), { status: nextStatus });
|
| 104 |
+
}}
|
| 105 |
+
className="animate-scale-in"
|
| 106 |
+
style={{
|
| 107 |
+
width: '100px', height: '100px',
|
| 108 |
+
background: t.status === 'available' ? 'rgba(76,217,100,0.05)' : 'rgba(0,0,0,0.02)',
|
| 109 |
+
border: `2px solid ${statusColor}`,
|
| 110 |
+
borderRadius: t.capacity > 4 ? '16px' : '50%',
|
| 111 |
+
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
| 112 |
+
boxShadow: t.status === 'occupied' ? '0 0 20px rgba(255,90,95,0.05)' : 'none',
|
| 113 |
+
cursor: 'pointer', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
| 114 |
+
position: 'relative'
|
| 115 |
+
}}
|
| 116 |
+
>
|
| 117 |
+
<span style={{ fontSize: '1.4rem', fontWeight: '900', color: statusColor }}>{t.number}</span>
|
| 118 |
+
<span style={{ fontSize: '0.65rem', opacity: 0.6, color: 'var(--text-muted)' }}>{t.capacity} p</span>
|
| 119 |
+
{t.status !== 'available' && (
|
| 120 |
+
<div style={{ position: 'absolute', top: '-5px', right: '-5px', width: 12, height: 12, borderRadius: '50%', background: statusColor, border: '2px solid var(--bg-card)' }} />
|
| 121 |
+
)}
|
| 122 |
+
</div>
|
| 123 |
+
);
|
| 124 |
+
})}
|
| 125 |
+
{tables.length === 0 && (
|
| 126 |
+
<div style={{ gridColumn: '1 / -1', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.1 }}>
|
| 127 |
+
<Grid size={80} />
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
</div>
|
| 131 |
+
</section>
|
| 132 |
+
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const inputStyle = { width: '100%', padding: '0.8rem', borderRadius: '8px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)', color: 'var(--text-main)', outline: 'none' };
|
| 139 |
+
const labelStyle = { display: 'block', fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.5rem' };
|
frontend/src/components/admin/UserManager.jsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { db } from '../../firebase/config';
|
| 3 |
+
import { ref, onValue, set, update } from 'firebase/database';
|
| 4 |
+
import { Users, Shield, UserPlus, Trash2, CheckCircle, Activity, ShieldCheck, UserCog } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
export default function UserManager() {
|
| 7 |
+
const [employees, setEmployees] = useState([]);
|
| 8 |
+
const [newEmployee, setNewEmployee] = useState({ email: '', role: 'mesero', name: '' });
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
onValue(ref(db, 'users'), (snapshot) => {
|
| 12 |
+
const data = snapshot.val();
|
| 13 |
+
if (data) {
|
| 14 |
+
setEmployees(Object.keys(data).map(uid => ({ uid, ...data[uid] })));
|
| 15 |
+
}
|
| 16 |
+
});
|
| 17 |
+
}, []);
|
| 18 |
+
|
| 19 |
+
const handleUpdateRole = async (uid, newRole) => {
|
| 20 |
+
await update(ref(db, `users/${uid}`), { role: newRole });
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const roleColors = {
|
| 24 |
+
admin: 'var(--primary)',
|
| 25 |
+
mesero: 'var(--success)',
|
| 26 |
+
cocina: 'var(--warning)',
|
| 27 |
+
cajero: '#50ADE6'
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="animate-fade-in" style={{ padding: '0 1rem' }}>
|
| 32 |
+
<header style={{ marginBottom: '2.5rem' }}>
|
| 33 |
+
<h2 className="text-gradient" style={{ fontSize: '2rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 34 |
+
<UserCog size={28} /> Equipo y Roles
|
| 35 |
+
</h2>
|
| 36 |
+
<p style={{ color: 'var(--text-muted)' }}>Control de acceso y permisos para empleados</p>
|
| 37 |
+
</header>
|
| 38 |
+
|
| 39 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '1.5rem' }}>
|
| 40 |
+
{employees.map(emp => (
|
| 41 |
+
<div key={emp.uid} className="glass-card" style={{ padding: '1.5rem', borderLeft: `4px solid ${roleColors[emp.role] || '#fff'}` }}>
|
| 42 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
|
| 43 |
+
<div>
|
| 44 |
+
<h3 style={{ fontSize: '1.1rem', fontWeight: '700' }}>{emp.name || 'Sin Nombre'}</h3>
|
| 45 |
+
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{emp.email}</p>
|
| 46 |
+
</div>
|
| 47 |
+
<div style={{ background: 'rgba(0,0,0,0.03)', padding: '0.5rem', borderRadius: '50%' }}>
|
| 48 |
+
<Shield size={20} style={{ color: roleColors[emp.role] }} />
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div style={{ marginBottom: '1.5rem' }}>
|
| 53 |
+
<label style={labelStyle}>Rol Asignado</label>
|
| 54 |
+
<select
|
| 55 |
+
value={emp.role}
|
| 56 |
+
onChange={(e) => handleUpdateRole(emp.uid, e.target.value)}
|
| 57 |
+
style={{...inputStyle, background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)'}}
|
| 58 |
+
>
|
| 59 |
+
<option value="admin">Administrador (Total)</option>
|
| 60 |
+
<option value="mesero">Mesero (POS)</option>
|
| 61 |
+
<option value="cocina">Cocina (KDS)</option>
|
| 62 |
+
<option value="cajero">Cajero (Caja/POS)</option>
|
| 63 |
+
</select>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 67 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.7rem', color: 'var(--success)' }}>
|
| 68 |
+
<Activity size={12} /> Activo
|
| 69 |
+
</div>
|
| 70 |
+
<button style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer' }} title="Revocar Acceso">
|
| 71 |
+
<Trash2 size={16} />
|
| 72 |
+
</button>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
))}
|
| 76 |
+
|
| 77 |
+
<div className="glass-card" style={{ border: '2px dashed var(--border-subtle)', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center', padding: '2rem', gap: '1rem' }}>
|
| 78 |
+
<div style={{ background: 'rgba(0,0,0,0.03)', padding: '1rem', borderRadius: '50%', color: 'var(--text-muted)' }}>
|
| 79 |
+
<UserPlus size={32} />
|
| 80 |
+
</div>
|
| 81 |
+
<h3 style={{ fontSize: '1rem', fontWeight: '700' }}>Registrar Nuevo Empleado</h3>
|
| 82 |
+
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>El empleado debe registrarse primero en la app para aparecer aquí.</p>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div className="glass-panel" style={{ marginTop: '3rem', padding: '2rem' }}>
|
| 87 |
+
<h3 style={{ fontSize: '1.1rem', marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
| 88 |
+
<ShieldCheck size={18} /> Permisos por Rol
|
| 89 |
+
</h3>
|
| 90 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.5rem' }}>
|
| 91 |
+
{[
|
| 92 |
+
{ role: 'Admin', rights: 'Control total de inventario, finanzas y menú.' },
|
| 93 |
+
{ role: 'Mesero', rights: 'Toma de pedidos, cierre de cuentas y mapa de mesas.' },
|
| 94 |
+
{ role: 'Cocina', rights: 'Visualización de tickets y cambio de estado de preparación.' },
|
| 95 |
+
{ role: 'Cajero', rights: 'Gestión de arqueo de caja y cobros de POS.' }
|
| 96 |
+
].map(r => (
|
| 97 |
+
<div key={r.role}>
|
| 98 |
+
<h4 style={{ fontSize: '0.9rem', fontWeight: '800', marginBottom: '0.5rem' }}>{r.role}</h4>
|
| 99 |
+
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', lineHeight: '1.4' }}>{r.rights}</p>
|
| 100 |
+
</div>
|
| 101 |
+
))}
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const inputStyle = { width: '100%', padding: '0.8rem', borderRadius: '8px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)', color: 'var(--text-main)', outline: 'none' };
|
| 109 |
+
const labelStyle = { display: 'block', fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.5rem' };
|
frontend/src/context/AuthContext.jsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useContext, useEffect, useState } from 'react';
|
| 2 |
+
import { auth, db } from '../firebase/config';
|
| 3 |
+
import { onAuthStateChanged, signInWithEmailAndPassword, signOut } from 'firebase/auth';
|
| 4 |
+
import { ref, get } from 'firebase/database';
|
| 5 |
+
|
| 6 |
+
export const AuthContext = createContext();
|
| 7 |
+
|
| 8 |
+
export const useAuth = () => {
|
| 9 |
+
return useContext(AuthContext);
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export const AuthProvider = ({ children }) => {
|
| 13 |
+
const [currentUser, setCurrentUser] = useState(null);
|
| 14 |
+
const [userRole, setUserRole] = useState(null);
|
| 15 |
+
const [loading, setLoading] = useState(true);
|
| 16 |
+
|
| 17 |
+
const login = async (email, password) => {
|
| 18 |
+
return signInWithEmailAndPassword(auth, email, password);
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const logout = () => {
|
| 22 |
+
return signOut(auth);
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
const unsubscribe = onAuthStateChanged(auth, async (user) => {
|
| 27 |
+
if (user) {
|
| 28 |
+
setCurrentUser(user);
|
| 29 |
+
// Obtener rol del usuario desde DB
|
| 30 |
+
try {
|
| 31 |
+
const userRef = ref(db, `users/${user.uid}`);
|
| 32 |
+
const snapshot = await get(userRef);
|
| 33 |
+
if (snapshot.exists()) {
|
| 34 |
+
setUserRole(snapshot.val().role);
|
| 35 |
+
} else {
|
| 36 |
+
console.log("Rol no definido para este usuario = asume default");
|
| 37 |
+
setUserRole('comensal');
|
| 38 |
+
}
|
| 39 |
+
} catch (error) {
|
| 40 |
+
console.error("Error al obtener el rol del usuario", error);
|
| 41 |
+
}
|
| 42 |
+
} else {
|
| 43 |
+
setCurrentUser(null);
|
| 44 |
+
setUserRole(null);
|
| 45 |
+
}
|
| 46 |
+
setLoading(false);
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
return unsubscribe;
|
| 50 |
+
}, []);
|
| 51 |
+
|
| 52 |
+
const value = {
|
| 53 |
+
currentUser,
|
| 54 |
+
userRole,
|
| 55 |
+
login,
|
| 56 |
+
logout
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<AuthContext.Provider value={value}>
|
| 61 |
+
{!loading && children}
|
| 62 |
+
</AuthContext.Provider>
|
| 63 |
+
);
|
| 64 |
+
};
|
frontend/src/firebase/config.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { initializeApp } from "firebase/app";
|
| 2 |
+
import { getAuth } from "firebase/auth";
|
| 3 |
+
import { getDatabase } from "firebase/database";
|
| 4 |
+
import { getStorage } from "firebase/storage";
|
| 5 |
+
|
| 6 |
+
const firebaseConfig = {
|
| 7 |
+
apiKey: "AIzaSyBm2Tvsz7SHtWEWO-97p3gvqC76iqt05VM",
|
| 8 |
+
authDomain: "rest-fc379.firebaseapp.com",
|
| 9 |
+
databaseURL: "https://rest-fc379-default-rtdb.firebaseio.com",
|
| 10 |
+
projectId: "rest-fc379",
|
| 11 |
+
storageBucket: "rest-fc379.firebasestorage.app",
|
| 12 |
+
messagingSenderId: "119970678093",
|
| 13 |
+
appId: "1:119970678093:web:16fb097882249fdce0e304",
|
| 14 |
+
measurementId: "G-ZDEW4Q04NN"
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
const app = initializeApp(firebaseConfig);
|
| 18 |
+
export const auth = getAuth(app);
|
| 19 |
+
export const db = getDatabase(app);
|
| 20 |
+
export const storage = getStorage(app);
|
| 21 |
+
|
| 22 |
+
export default app;
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap');
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
/* Premium Light Theme Core Palette */
|
| 5 |
+
--bg-base: #f8f9fa;
|
| 6 |
+
--bg-surface: #ffffff;
|
| 7 |
+
--bg-glass: rgba(255, 255, 255, 0.85);
|
| 8 |
+
--bg-glass-hover: rgba(245, 245, 250, 0.9);
|
| 9 |
+
--bg-card: #ffffff;
|
| 10 |
+
|
| 11 |
+
/* Brand Accents */
|
| 12 |
+
--primary: #FF5A5F;
|
| 13 |
+
--primary-hover: #ff4046;
|
| 14 |
+
--primary-glow: rgba(255, 90, 95, 0.15);
|
| 15 |
+
--secondary: #00A699;
|
| 16 |
+
--secondary-hover: #008f84;
|
| 17 |
+
|
| 18 |
+
/* Text */
|
| 19 |
+
--text-main: #1a1a20;
|
| 20 |
+
--text-muted: #6b6b7b;
|
| 21 |
+
--text-disabled: #a5a5b8;
|
| 22 |
+
|
| 23 |
+
/* Status Colors */
|
| 24 |
+
--success: #20C997;
|
| 25 |
+
--warning: #F5A623;
|
| 26 |
+
--danger: #FF3B30;
|
| 27 |
+
--info: #32ADE6;
|
| 28 |
+
|
| 29 |
+
/* Borders & Shadows */
|
| 30 |
+
--border-subtle: rgba(0, 0, 0, 0.08);
|
| 31 |
+
--border-focus: rgba(255, 90, 95, 0.3);
|
| 32 |
+
--shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.05);
|
| 33 |
+
--shadow-md: 0 12px 30px rgba(0, 0, 0, 0.08);
|
| 34 |
+
--shadow-glow: 0 0 20px var(--primary-glow);
|
| 35 |
+
--glass-blur: blur(20px);
|
| 36 |
+
--glass-border: 1px solid var(--border-subtle);
|
| 37 |
+
|
| 38 |
+
/* Transitions */
|
| 39 |
+
--transition-fast: 0.15s ease;
|
| 40 |
+
--transition-normal: 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
| 41 |
+
--transition-slow: 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
| 42 |
+
|
| 43 |
+
/* Radii */
|
| 44 |
+
--radius-sm: 8px;
|
| 45 |
+
--radius-md: 16px;
|
| 46 |
+
--radius-lg: 24px;
|
| 47 |
+
--radius-pill: 9999px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
* {
|
| 51 |
+
margin: 0;
|
| 52 |
+
padding: 0;
|
| 53 |
+
box-sizing: border-box;
|
| 54 |
+
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
body {
|
| 58 |
+
background-color: var(--bg-base);
|
| 59 |
+
color: var(--text-main);
|
| 60 |
+
min-height: 100vh;
|
| 61 |
+
overflow-x: hidden;
|
| 62 |
+
-webkit-font-smoothing: antialiased;
|
| 63 |
+
background-image:
|
| 64 |
+
radial-gradient(circle at 10% 20%, rgba(255, 90, 95, 0.02) 0%, transparent 30%),
|
| 65 |
+
radial-gradient(circle at 90% 80%, rgba(0, 166, 153, 0.02) 0%, transparent 30%);
|
| 66 |
+
background-attachment: fixed;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
a {
|
| 70 |
+
color: inherit;
|
| 71 |
+
text-decoration: none;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
button {
|
| 75 |
+
background: none;
|
| 76 |
+
border: none;
|
| 77 |
+
color: inherit;
|
| 78 |
+
cursor: pointer;
|
| 79 |
+
font-family: inherit;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/* Base Glassmorphic Container */
|
| 83 |
+
.glass-panel {
|
| 84 |
+
background: var(--bg-glass);
|
| 85 |
+
backdrop-filter: var(--glass-blur);
|
| 86 |
+
-webkit-backdrop-filter: var(--glass-blur);
|
| 87 |
+
border: var(--glass-border);
|
| 88 |
+
border-radius: var(--radius-md);
|
| 89 |
+
box-shadow: var(--shadow-md);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* Glass Card with Hover */
|
| 93 |
+
.glass-card {
|
| 94 |
+
background: var(--bg-card);
|
| 95 |
+
backdrop-filter: blur(12px);
|
| 96 |
+
-webkit-backdrop-filter: blur(12px);
|
| 97 |
+
border: var(--glass-border);
|
| 98 |
+
border-radius: var(--radius-md);
|
| 99 |
+
transition: all var(--transition-normal);
|
| 100 |
+
padding: 1.5rem;
|
| 101 |
+
box-shadow: var(--shadow-sm);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.glass-card:hover {
|
| 105 |
+
transform: translateY(-4px);
|
| 106 |
+
background: var(--bg-glass-hover);
|
| 107 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 108 |
+
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.06);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/* Premium Buttons */
|
| 112 |
+
.btn-primary {
|
| 113 |
+
background: var(--primary);
|
| 114 |
+
color: #fff;
|
| 115 |
+
padding: 0.75rem 1.75rem;
|
| 116 |
+
border-radius: var(--radius-pill);
|
| 117 |
+
font-weight: 600;
|
| 118 |
+
letter-spacing: 0.5px;
|
| 119 |
+
transition: all var(--transition-fast);
|
| 120 |
+
box-shadow: 0 4px 14px var(--primary-glow);
|
| 121 |
+
display: inline-flex;
|
| 122 |
+
align-items: center;
|
| 123 |
+
justify-content: center;
|
| 124 |
+
gap: 0.5rem;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.btn-primary:hover {
|
| 128 |
+
background: var(--primary-hover);
|
| 129 |
+
transform: scale(1.02);
|
| 130 |
+
box-shadow: 0 6px 20px var(--primary-glow);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.btn-primary:active {
|
| 134 |
+
transform: scale(0.98);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.btn-glass {
|
| 138 |
+
background: rgba(0,0,0,0.03);
|
| 139 |
+
border: 1px solid rgba(0,0,0,0.08);
|
| 140 |
+
color: var(--text-main);
|
| 141 |
+
padding: 0.75rem 1.75rem;
|
| 142 |
+
border-radius: var(--radius-pill);
|
| 143 |
+
font-weight: 500;
|
| 144 |
+
transition: all var(--transition-fast);
|
| 145 |
+
backdrop-filter: blur(8px);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.btn-glass:hover {
|
| 149 |
+
background: rgba(0,0,0,0.06);
|
| 150 |
+
border-color: rgba(0,0,0,0.12);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/* Typography styles */
|
| 154 |
+
.text-gradient {
|
| 155 |
+
background: linear-gradient(135deg, #1a1a20 0%, #6b6b7b 100%);
|
| 156 |
+
-webkit-background-clip: text;
|
| 157 |
+
-webkit-text-fill-color: transparent;
|
| 158 |
+
background-clip: text;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.text-gradient-primary {
|
| 162 |
+
background: linear-gradient(135deg, var(--primary) 0%, #ff8a8e 100%);
|
| 163 |
+
-webkit-background-clip: text;
|
| 164 |
+
-webkit-text-fill-color: transparent;
|
| 165 |
+
background-clip: text;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/* Layout Utilities */
|
| 169 |
+
.app-container {
|
| 170 |
+
display: flex;
|
| 171 |
+
min-height: 100vh;
|
| 172 |
+
width: 100%;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.main-content {
|
| 176 |
+
flex: 1;
|
| 177 |
+
padding: 2rem;
|
| 178 |
+
display: flex;
|
| 179 |
+
flex-direction: column;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/* Animations */
|
| 183 |
+
@keyframes fadeIn {
|
| 184 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 185 |
+
to { opacity: 1; transform: translateY(0); }
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.animate-fade-in {
|
| 189 |
+
animation: fadeIn 0.4s ease-out forwards;
|
| 190 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/src/pages/AdminDashboard.jsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import { useNavigate } from 'react-router-dom';
|
| 4 |
+
import { LayoutDashboard, Utensils, Box, PieChart, LogOut, DollarSign, UtensilsCrossed, Users, Settings, Save, ShieldCheck, Truck } from 'lucide-react';
|
| 5 |
+
import DashboardOverview from '../components/admin/DashboardOverview';
|
| 6 |
+
import MenuEditor from '../components/admin/MenuEditor';
|
| 7 |
+
import InventoryControl from '../components/admin/InventoryControl';
|
| 8 |
+
import Reports from '../components/admin/Reports';
|
| 9 |
+
import FinanceManager from '../components/admin/FinanceManager';
|
| 10 |
+
import UserManager from '../components/admin/UserManager';
|
| 11 |
+
import TableManager from '../components/admin/TableManager';
|
| 12 |
+
import CRMManager from '../components/admin/CRMManager';
|
| 13 |
+
import SettingsPlus from '../components/admin/SettingsPlus';
|
| 14 |
+
|
| 15 |
+
export default function AdminDashboard() {
|
| 16 |
+
const { logout, currentUser } = useAuth();
|
| 17 |
+
const navigate = useNavigate();
|
| 18 |
+
const [activeTab, setActiveTab] = useState('overview');
|
| 19 |
+
|
| 20 |
+
const handleLogout = async () => {
|
| 21 |
+
await logout();
|
| 22 |
+
navigate('/login');
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const navItems = [
|
| 26 |
+
{ id: 'overview', label: 'Resumen', icon: <LayoutDashboard size={20} /> },
|
| 27 |
+
{ id: 'menu', label: 'Menú & Precios', icon: <Utensils size={20} /> },
|
| 28 |
+
{ id: 'inventory', label: 'Control de Stock', icon: <Box size={20} /> },
|
| 29 |
+
{ id: 'reports', label: 'Reportes y Analítica', icon: <PieChart size={20} /> },
|
| 30 |
+
{ id: 'cash', label: 'Finanzas & Caja', icon: <DollarSign size={20} /> },
|
| 31 |
+
{ id: 'users', label: 'Gestión de Personal', icon: <Users size={20} /> },
|
| 32 |
+
{ id: 'tables', label: 'Arquitectura Salón', icon: <LayoutDashboard size={20} /> },
|
| 33 |
+
{ id: 'crm', label: 'CRM & Operaciones', icon: <Truck size={20} /> },
|
| 34 |
+
{ id: 'settings', label: 'Configuración ERP', icon: <Settings size={20} /> },
|
| 35 |
+
{ id: 'kitchen', label: 'Pantalla Cocina', icon: <UtensilsCrossed size={20} />, action: () => window.open('/kitchen', '_blank') },
|
| 36 |
+
{ id: 'menu_public', label: 'Carta Digital', icon: <Utensils size={20} />, action: () => window.open('/menu', '_blank') }
|
| 37 |
+
];
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div className="app-container">
|
| 41 |
+
{/* Sidebar */}
|
| 42 |
+
<aside className="glass-panel" style={{ width: '280px', borderRadius: '0', borderRight: '1px solid var(--border-subtle)', display: 'flex', flexDirection: 'column' }}>
|
| 43 |
+
<div style={{ padding: '2rem 1.5rem', borderBottom: '1px solid var(--border-subtle)' }}>
|
| 44 |
+
<h1 className="text-gradient" style={{ fontSize: '1.5rem', fontWeight: '800' }}>RESTO PLUS</h1>
|
| 45 |
+
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginTop: '0.25rem' }}>Sistema ERP Inteligente</p>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<nav style={{ flex: 1, padding: '1.5rem 1rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
| 49 |
+
{navItems.map(item => (
|
| 50 |
+
<button
|
| 51 |
+
key={item.id}
|
| 52 |
+
onClick={() => {
|
| 53 |
+
if (item.action) item.action();
|
| 54 |
+
else setActiveTab(item.id);
|
| 55 |
+
}}
|
| 56 |
+
style={{
|
| 57 |
+
display: 'flex', alignItems: 'center', gap: '1rem', padding: '0.8rem 1rem', width: '100%',
|
| 58 |
+
borderRadius: 'var(--radius-sm)', transition: 'all var(--transition-fast)',
|
| 59 |
+
background: activeTab === item.id ? 'rgba(255,90,95,0.1)' : 'transparent',
|
| 60 |
+
color: activeTab === item.id ? 'var(--primary)' : 'var(--text-muted)',
|
| 61 |
+
fontWeight: activeTab === item.id ? '600' : '500',
|
| 62 |
+
borderLeft: activeTab === item.id ? '3px solid var(--primary)' : '3px solid transparent'
|
| 63 |
+
}}
|
| 64 |
+
>
|
| 65 |
+
{item.icon}
|
| 66 |
+
{item.label}
|
| 67 |
+
</button>
|
| 68 |
+
))}
|
| 69 |
+
</nav>
|
| 70 |
+
|
| 71 |
+
<div style={{ padding: '1.5rem 1rem' }}>
|
| 72 |
+
<button onClick={handleLogout} className="btn-glass" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem' }}>
|
| 73 |
+
<LogOut size={18} /> Salir
|
| 74 |
+
</button>
|
| 75 |
+
</div>
|
| 76 |
+
</aside>
|
| 77 |
+
|
| 78 |
+
{/* Main Content Area */}
|
| 79 |
+
<main className="main-content" style={{ overflowY: 'auto', background: 'var(--bg-base)' }}>
|
| 80 |
+
<div className="animate-fade-in" style={{ width: '100%', maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
|
| 81 |
+
{activeTab === 'overview' && <DashboardOverview />}
|
| 82 |
+
{activeTab === 'menu' && <MenuEditor />}
|
| 83 |
+
{activeTab === 'inventory' && <InventoryControl />}
|
| 84 |
+
{activeTab === 'reports' && <Reports />}
|
| 85 |
+
{activeTab === 'cash' && <FinanceManager />}
|
| 86 |
+
{activeTab === 'users' && <UserManager />}
|
| 87 |
+
{activeTab === 'tables' && <TableManager />}
|
| 88 |
+
{activeTab === 'crm' && <CRMManager />}
|
| 89 |
+
{activeTab === 'settings' && <SettingsPlus />}
|
| 90 |
+
</div>
|
| 91 |
+
</main>
|
| 92 |
+
</div>
|
| 93 |
+
);
|
| 94 |
+
}
|
frontend/src/pages/CustomerMenu.jsx
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { db } from '../firebase/config';
|
| 4 |
+
import { ref, onValue } from 'firebase/database';
|
| 5 |
+
import { Utensils, Star, Clock, MapPin, ChefHat, Instagram, Facebook, ArrowRight } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export default function CustomerMenu() {
|
| 8 |
+
const [menuItems, setMenuItems] = useState([]);
|
| 9 |
+
const [categories, setCategories] = useState([]);
|
| 10 |
+
const [activeCategory, setActiveCategory] = useState('Todos');
|
| 11 |
+
const [menuTheme, setMenuTheme] = useState('light'); // 'dark' or 'light'
|
| 12 |
+
const navigate = useNavigate();
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
// Escuchar el menú
|
| 16 |
+
const menuRef = ref(db, 'menu');
|
| 17 |
+
onValue(menuRef, (snapshot) => {
|
| 18 |
+
const data = snapshot.val();
|
| 19 |
+
if (data) {
|
| 20 |
+
const list = Object.keys(data).map(id => ({ id, ...data[id] })).filter(item => item.active !== false);
|
| 21 |
+
setMenuItems(list);
|
| 22 |
+
|
| 23 |
+
const cats = [...new Set(list.map(item => item.category))];
|
| 24 |
+
setCategories(['Todos', ...cats]);
|
| 25 |
+
}
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
// Escuchar el tema configurado por el admin
|
| 29 |
+
const themeRef = ref(db, 'config/menuTheme');
|
| 30 |
+
onValue(themeRef, (snapshot) => {
|
| 31 |
+
if (snapshot.exists()) {
|
| 32 |
+
setMenuTheme(snapshot.val());
|
| 33 |
+
}
|
| 34 |
+
});
|
| 35 |
+
}, []);
|
| 36 |
+
|
| 37 |
+
const filteredItems = activeCategory === 'Todos'
|
| 38 |
+
? menuItems
|
| 39 |
+
: menuItems.filter(item => item.category === activeCategory);
|
| 40 |
+
|
| 41 |
+
const isDark = menuTheme === 'dark';
|
| 42 |
+
|
| 43 |
+
const themeColors = {
|
| 44 |
+
bg: isDark ? '#0a0a0a' : '#ffffff',
|
| 45 |
+
text: isDark ? '#ffffff' : '#1a1a1a',
|
| 46 |
+
muted: isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)',
|
| 47 |
+
card: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)',
|
| 48 |
+
border: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
|
| 49 |
+
accent: '#FF6B6B'
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div style={{
|
| 54 |
+
minHeight: '100vh',
|
| 55 |
+
background: themeColors.bg,
|
| 56 |
+
color: themeColors.text,
|
| 57 |
+
fontFamily: "'Outfit', sans-serif",
|
| 58 |
+
transition: 'all 0.5s ease'
|
| 59 |
+
}}>
|
| 60 |
+
|
| 61 |
+
{/* Hero Section */}
|
| 62 |
+
<section style={{
|
| 63 |
+
height: '60vh', position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
| 64 |
+
background: `linear-gradient(rgba(0,0,0,${isDark ? 0.7 : 0.4}), rgba(0,0,0,${isDark ? 0.7 : 0.4})), url('https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?auto=format&fit=crop&q=80&w=2070')`,
|
| 65 |
+
backgroundSize: 'cover', backgroundPosition: 'center'
|
| 66 |
+
}}>
|
| 67 |
+
<div style={{ textAlign: 'center', padding: '0 20px' }} className="animate-slide-up">
|
| 68 |
+
<h1 style={{ fontSize: '4rem', fontWeight: '900', marginBottom: '1rem', letterSpacing: '-2px' }}>Gourmet Experience</h1>
|
| 69 |
+
<p style={{ fontSize: '1.25rem', opacity: 0.9, maxWidth: '600px', margin: '0 auto' }}>Descubre una fusión de sabores únicos, preparados con pasión y los ingredientes más frescos.</p>
|
| 70 |
+
<div style={{ marginTop: '2rem', display: 'flex', gap: '1rem', justifyContent: 'center' }}>
|
| 71 |
+
<button onClick={() => navigate('/login')} className="btn-primary" style={{ padding: '12px 30px', borderRadius: '30px' }}>
|
| 72 |
+
Reservar Mesa <ArrowRight size={18} style={{ marginLeft: '10px' }} />
|
| 73 |
+
</button>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Badges */}
|
| 78 |
+
<div style={{
|
| 79 |
+
position: 'absolute', bottom: '-40px', left: '50%', transform: 'translateX(-50%)',
|
| 80 |
+
display: 'flex', gap: '30px', width: '100%', justifyContent: 'center', maxWidth: '800px'
|
| 81 |
+
}}>
|
| 82 |
+
{[
|
| 83 |
+
{ icon: <Star size={20} />, text: "4.9 Estrellas" },
|
| 84 |
+
{ icon: <Clock size={20} />, text: "30-45 min" },
|
| 85 |
+
{ icon: <MapPin size={20} />, text: "Centro Histórico" }
|
| 86 |
+
].map((badge, i) => (
|
| 87 |
+
<div key={i} className="glass-card" style={{
|
| 88 |
+
padding: '15px 25px', borderRadius: '15px', display: 'flex', alignItems: 'center', gap: '10px',
|
| 89 |
+
background: isDark ? 'rgba(30,30,30,0.8)' : 'rgba(255,255,255,0.9)',
|
| 90 |
+
color: isDark ? '#fff' : '#1a1a1a', border: `1px solid ${themeColors.border}`,
|
| 91 |
+
boxShadow: '0 10px 30px rgba(0,0,0,0.1)'
|
| 92 |
+
}}>
|
| 93 |
+
<span style={{ color: themeColors.accent }}>{badge.icon}</span>
|
| 94 |
+
<span style={{ fontWeight: '600', fontSize: '0.9rem' }}>{badge.text}</span>
|
| 95 |
+
</div>
|
| 96 |
+
))}
|
| 97 |
+
</div>
|
| 98 |
+
</section>
|
| 99 |
+
|
| 100 |
+
{/* Menu Section */}
|
| 101 |
+
<section style={{ maxWidth: '1200px', margin: '100px auto', padding: '0 20px' }}>
|
| 102 |
+
<div style={{ textAlign: 'center', marginBottom: '60px' }}>
|
| 103 |
+
<h2 style={{ fontSize: '2.5rem', fontWeight: '800', marginBottom: '1rem' }}>Nuestra Carta</h2>
|
| 104 |
+
<div style={{ width: '60px', height: '4px', background: themeColors.accent, margin: '0 auto', borderRadius: '2px' }}></div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Category Tabs */}
|
| 108 |
+
<div style={{
|
| 109 |
+
display: 'flex', justifyContent: 'center', gap: '10px', marginBottom: '50px',
|
| 110 |
+
overflowX: 'auto', padding: '10px 0', scrollbarWidth: 'none'
|
| 111 |
+
}}>
|
| 112 |
+
{categories.map(cat => (
|
| 113 |
+
<button
|
| 114 |
+
key={cat}
|
| 115 |
+
onClick={() => setActiveCategory(cat)}
|
| 116 |
+
style={{
|
| 117 |
+
padding: '12px 25px', borderRadius: '30px', border: 'none', cursor: 'pointer',
|
| 118 |
+
background: activeCategory === cat ? themeColors.accent : themeColors.card,
|
| 119 |
+
color: activeCategory === cat ? '#fff' : themeColors.text,
|
| 120 |
+
fontWeight: '600', transition: 'all 0.3s ease', whiteSpace: 'nowrap',
|
| 121 |
+
border: `1px solid ${activeCategory === cat ? themeColors.accent : themeColors.border}`
|
| 122 |
+
}}
|
| 123 |
+
>
|
| 124 |
+
{cat}
|
| 125 |
+
</button>
|
| 126 |
+
))}
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
{/* Menu Grid */}
|
| 130 |
+
<div style={{
|
| 131 |
+
display: 'grid',
|
| 132 |
+
gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))',
|
| 133 |
+
gap: '30px'
|
| 134 |
+
}}>
|
| 135 |
+
{filteredItems.map(item => (
|
| 136 |
+
<div key={item.id} className="glass-card" style={{
|
| 137 |
+
padding: '0', overflow: 'hidden', border: `1px solid ${themeColors.border}`,
|
| 138 |
+
background: themeColors.card, position: 'relative', transition: 'transform 0.3s ease'
|
| 139 |
+
}} onMouseEnter={e => e.currentTarget.style.transform = 'translateY(-10px)'} onMouseLeave={e => e.currentTarget.style.transform = 'translateY(0)'}>
|
| 140 |
+
|
| 141 |
+
{/* Dish Image */}
|
| 142 |
+
<div style={{ height: '240px', overflow: 'hidden', position: 'relative' }}>
|
| 143 |
+
<img
|
| 144 |
+
src={item.image || 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?auto=format&fit=crop&q=80&w=1760'}
|
| 145 |
+
alt={item.name}
|
| 146 |
+
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
| 147 |
+
/>
|
| 148 |
+
<div style={{
|
| 149 |
+
position: 'absolute', top: '15px', right: '15px',
|
| 150 |
+
background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '5px 15px',
|
| 151 |
+
borderRadius: '20px', fontWeight: '800', fontSize: '1.1rem', backdropFilter: 'blur(5px)'
|
| 152 |
+
}}>
|
| 153 |
+
${item.price}
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
{/* Dish Details */}
|
| 158 |
+
<div style={{ padding: '25px' }}>
|
| 159 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
| 160 |
+
<span style={{ fontSize: '0.75rem', textTransform: 'uppercase', color: themeColors.accent, fontWeight: '800', letterSpacing: '1px' }}>{item.category}</span>
|
| 161 |
+
</div>
|
| 162 |
+
<h3 style={{ fontSize: '1.4rem', fontWeight: '700', marginBottom: '10px' }}>{item.name}</h3>
|
| 163 |
+
<p style={{ color: themeColors.muted, fontSize: '0.95rem', lineHeight: '1.6' }}>Preparado con ingredientes frescos de la estación y el toque secreto de nuestro chef.</p>
|
| 164 |
+
<div style={{ marginTop: '20px', display: 'flex', gap: '15px' }}>
|
| 165 |
+
<span style={{ fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: '5px', color: themeColors.muted }}>
|
| 166 |
+
<ChefHat size={16} /> Especialidad
|
| 167 |
+
</span>
|
| 168 |
+
<span style={{ fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: '5px', color: themeColors.muted }}>
|
| 169 |
+
<Clock size={16} /> 20 min
|
| 170 |
+
</span>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
))}
|
| 175 |
+
</div>
|
| 176 |
+
</section>
|
| 177 |
+
|
| 178 |
+
{/* Footer */}
|
| 179 |
+
<footer style={{
|
| 180 |
+
padding: '80px 20px', borderTop: `1px solid ${themeColors.border}`,
|
| 181 |
+
background: isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.02)', textAlign: 'center'
|
| 182 |
+
}}>
|
| 183 |
+
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
|
| 184 |
+
<h2 style={{ fontSize: '1.8rem', fontWeight: '900', marginBottom: '1.5rem' }}>Restaurant OS</h2>
|
| 185 |
+
<p style={{ color: themeColors.muted, marginBottom: '30px' }}>Siguenos en nuestras redes sociales para estar al tanto de nuestras promociones y eventos especiales.</p>
|
| 186 |
+
<div style={{ display: 'flex', justifyContent: 'center', gap: '20px' }}>
|
| 187 |
+
<button onClick={() => {}} style={{ background: 'none', border: 'none', color: themeColors.text, cursor: 'pointer' }}><Instagram size={24} /></button>
|
| 188 |
+
<button onClick={() => {}} style={{ background: 'none', border: 'none', color: themeColors.text, cursor: 'pointer' }}><Facebook size={24} /></button>
|
| 189 |
+
</div>
|
| 190 |
+
<div style={{ marginTop: '50px', fontSize: '0.85rem', color: themeColors.muted }}>
|
| 191 |
+
© {new Date().getFullYear()} Gourmet Experience. Todos los derechos reservados.
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
</footer>
|
| 195 |
+
|
| 196 |
+
{/* Admin Quick Back (Floating) */}
|
| 197 |
+
<button
|
| 198 |
+
onClick={() => navigate('/login')}
|
| 199 |
+
style={{
|
| 200 |
+
position: 'fixed', bottom: '30px', right: '30px', width: '50px', height: '50px', borderRadius: '50%',
|
| 201 |
+
background: themeColors.accent, color: '#fff', border: 'none', cursor: 'pointer',
|
| 202 |
+
boxShadow: '0 10px 20px rgba(0,0,0,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100
|
| 203 |
+
}}
|
| 204 |
+
title="Admin Login"
|
| 205 |
+
>
|
| 206 |
+
<Utensils size={24} />
|
| 207 |
+
</button>
|
| 208 |
+
</div>
|
| 209 |
+
);
|
| 210 |
+
}
|
frontend/src/pages/KitchenView.jsx
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { db } from '../firebase/config';
|
| 3 |
+
import { ref, onValue, update } from 'firebase/database';
|
| 4 |
+
import { useAuth } from '../context/AuthContext';
|
| 5 |
+
import { useNavigate } from 'react-router-dom';
|
| 6 |
+
import { CheckCircle, Clock, UtensilsCrossed, LogOut, ExternalLink, PlayCircle, Flame } from 'lucide-react';
|
| 7 |
+
|
| 8 |
+
export default function KitchenView() {
|
| 9 |
+
const { logout } = useAuth();
|
| 10 |
+
const navigate = useNavigate();
|
| 11 |
+
const [orders, setOrders] = useState([]);
|
| 12 |
+
const lastOrderCount = useRef(0);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
const ordersRef = ref(db, 'orders');
|
| 16 |
+
onValue(ordersRef, (snapshot) => {
|
| 17 |
+
const data = snapshot.val();
|
| 18 |
+
if (data) {
|
| 19 |
+
const orderList = Object.keys(data).map(k => ({ id: k, ...data[k] }))
|
| 20 |
+
.filter(o => o.status !== 'completed')
|
| 21 |
+
.sort((a, b) => b.timestamp - a.timestamp);
|
| 22 |
+
|
| 23 |
+
// Alerta de sonido si hay nuevas órdenes
|
| 24 |
+
if (orderList.length > lastOrderCount.current) {
|
| 25 |
+
playNotificationSound();
|
| 26 |
+
}
|
| 27 |
+
lastOrderCount.current = orderList.length;
|
| 28 |
+
setOrders(orderList);
|
| 29 |
+
} else {
|
| 30 |
+
setOrders([]);
|
| 31 |
+
}
|
| 32 |
+
});
|
| 33 |
+
}, []);
|
| 34 |
+
|
| 35 |
+
const playNotificationSound = () => {
|
| 36 |
+
const audio = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3');
|
| 37 |
+
audio.play().catch(e => console.log("User interaction required for audio"));
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const updateStatus = async (id, status) => {
|
| 41 |
+
await update(ref(db, `orders/${id}`), { status });
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const handleLogout = async () => {
|
| 45 |
+
await logout();
|
| 46 |
+
navigate('/login');
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div className="app-container" style={{ flexDirection: 'column', background: 'var(--bg-base)', height: '100vh', overflow: 'hidden' }}>
|
| 51 |
+
<header className="glass-panel" style={{ padding: '1rem 2.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderRadius: '0', borderBottom: '1px solid var(--border-subtle)' }}>
|
| 52 |
+
<h1 className="text-gradient" style={{ fontSize: '1.5rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 53 |
+
<UtensilsCrossed size={24} /> KDS - Centro de Cocina
|
| 54 |
+
</h1>
|
| 55 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem' }}>
|
| 56 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--success)', fontSize: '0.9rem' }}>
|
| 57 |
+
<div style={{ width: '8px', height: '8px', background: 'var(--success)', borderRadius: '50%', boxShadow: '0 0 10px var(--success)' }}></div>
|
| 58 |
+
En Vivo
|
| 59 |
+
</div>
|
| 60 |
+
<button className="btn-glass" onClick={handleLogout} style={{ padding: '0.5rem 1rem', color: 'var(--primary)' }}>
|
| 61 |
+
<LogOut size={16} /> Salir
|
| 62 |
+
</button>
|
| 63 |
+
</div>
|
| 64 |
+
</header>
|
| 65 |
+
|
| 66 |
+
<div className="main-content" style={{ padding: '2rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1.5rem', overflowY: 'auto' }}>
|
| 67 |
+
{orders.length === 0 ? (
|
| 68 |
+
<div style={{ gridColumn: '1 / -1', textAlign: 'center', paddingTop: '10vh', color: 'var(--text-muted)' }}>
|
| 69 |
+
<UtensilsCrossed size={60} style={{ opacity: 0.1, marginBottom: '1rem' }} />
|
| 70 |
+
<h2 style={{ fontSize: '1.5rem' }}>Cocina Despejada</h2>
|
| 71 |
+
<p>No hay pedidos en espera.</p>
|
| 72 |
+
</div>
|
| 73 |
+
) : (
|
| 74 |
+
orders.map((order) => (
|
| 75 |
+
<div key={order.id} className="glass-card animate-fade-in" style={{
|
| 76 |
+
padding: '1.5rem',
|
| 77 |
+
borderLeft: order.status === 'preparing' ? '4px solid var(--warning)' : '4px solid var(--primary)',
|
| 78 |
+
background: order.status === 'preparing' ? 'rgba(255,160,0,0.05)' : 'var(--bg-card)',
|
| 79 |
+
boxShadow: 'var(--shadow-sm)'
|
| 80 |
+
}}>
|
| 81 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1.25rem', alignItems: 'center' }}>
|
| 82 |
+
<span style={{ fontSize: '1.2rem', fontWeight: '800', color: order.status === 'preparing' ? 'var(--warning)' : 'var(--primary)' }}>
|
| 83 |
+
{order.table} {order.type === 'Llevar' && '🛍️'} {order.type === 'Delivery' && '🚀'}
|
| 84 |
+
</span>
|
| 85 |
+
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
| 86 |
+
{Math.floor((Date.now() - order.timestamp) / 60000)} min atrás
|
| 87 |
+
</span>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div style={{ marginBottom: '1.5rem', minHeight: '80px' }}>
|
| 91 |
+
{order.items.map((item, idx) => (
|
| 92 |
+
<div key={idx} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0', borderBottom: '1px solid var(--border-subtle)' }}>
|
| 93 |
+
<span style={{ fontWeight: '600' }}><span style={{ color: 'var(--primary)', fontWeight: '800' }}>{item.qty}</span> {item.name}</span>
|
| 94 |
+
</div>
|
| 95 |
+
))}
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
| 99 |
+
{order.status !== 'preparing' ? (
|
| 100 |
+
<button
|
| 101 |
+
className="btn-glass"
|
| 102 |
+
onClick={() => updateStatus(order.id, 'preparing')}
|
| 103 |
+
style={{ background: 'rgba(255,160,0,0.1)', color: '#FFB000' }}
|
| 104 |
+
>
|
| 105 |
+
<Flame size={18} /> Preparar
|
| 106 |
+
</button>
|
| 107 |
+
) : (
|
| 108 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.8rem', color: 'var(--warning)', fontWeight: '700' }}>
|
| 109 |
+
ENCENDIDO...
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
+
<button
|
| 113 |
+
className="btn-primary"
|
| 114 |
+
onClick={() => updateStatus(order.id, 'completed')}
|
| 115 |
+
style={{ background: 'var(--success)', color: '#fff' }}
|
| 116 |
+
>
|
| 117 |
+
<CheckCircle size={18} /> Listo
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
))
|
| 122 |
+
)}
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
);
|
| 126 |
+
}
|
frontend/src/pages/Login.jsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import { useNavigate } from 'react-router-dom';
|
| 4 |
+
import { User, ShieldCheck, Utensils, ArrowRightCircle } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
export default function Login() {
|
| 7 |
+
const [email, setEmail] = useState('admin@rest-os.com');
|
| 8 |
+
const [password, setPassword] = useState('admin-password123');
|
| 9 |
+
const [loading, setLoading] = useState(false);
|
| 10 |
+
const [error, setError] = useState('');
|
| 11 |
+
const [loginMode, setLoginMode] = useState('admin'); // 'admin' or 'mesero'
|
| 12 |
+
|
| 13 |
+
const { login, currentUser } = useAuth();
|
| 14 |
+
const navigate = useNavigate();
|
| 15 |
+
|
| 16 |
+
const handleSubmit = async (e) => {
|
| 17 |
+
e.preventDefault();
|
| 18 |
+
setLoading(true);
|
| 19 |
+
setError('');
|
| 20 |
+
|
| 21 |
+
// Si ya hay una sesión activa, simplemente navegar
|
| 22 |
+
if (currentUser && currentUser.email === email) {
|
| 23 |
+
const targetRole = email.includes('admin') ? 'admin' : 'mesero';
|
| 24 |
+
navigate(targetRole === 'admin' ? '/admin' : '/pos');
|
| 25 |
+
setLoading(false);
|
| 26 |
+
return;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
try {
|
| 30 |
+
await login(email, password);
|
| 31 |
+
// navigation is handled by RootRedirect in App.jsx
|
| 32 |
+
} catch (err) {
|
| 33 |
+
setError('Credenciales inválidas. Por favor intente de nuevo.');
|
| 34 |
+
} finally {
|
| 35 |
+
setLoading(false);
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const setDemo = (role) => {
|
| 40 |
+
if (role === 'admin') {
|
| 41 |
+
setEmail('admin@rest-os.com');
|
| 42 |
+
setPassword('admin-password123');
|
| 43 |
+
setLoginMode('admin');
|
| 44 |
+
} else {
|
| 45 |
+
setEmail('mesero@rest-os.com');
|
| 46 |
+
setPassword('mesero-password123');
|
| 47 |
+
setLoginMode('mesero');
|
| 48 |
+
}
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="app-container" style={{
|
| 53 |
+
background: 'var(--bg-base)',
|
| 54 |
+
justifyContent: 'center', alignItems: 'center', minHeight: '100vh', padding: '20px'
|
| 55 |
+
}}>
|
| 56 |
+
{/* Icono de Ver Menú Público */}
|
| 57 |
+
<button
|
| 58 |
+
onClick={() => navigate('/menu')}
|
| 59 |
+
className="glass-card animate-fade-in"
|
| 60 |
+
style={{
|
| 61 |
+
position: 'absolute', top: '30px', right: '30px', padding: '12px 24px',
|
| 62 |
+
display: 'flex', alignItems: 'center', gap: '10px', color: 'var(--primary)',
|
| 63 |
+
cursor: 'pointer', border: '1px solid rgba(255,107,107,0.3)',
|
| 64 |
+
zIndex: 10
|
| 65 |
+
}}
|
| 66 |
+
>
|
| 67 |
+
<Utensils size={20} />
|
| 68 |
+
<span style={{ fontWeight: '600' }}>Ver Carta Digital</span>
|
| 69 |
+
<ArrowRightCircle size={18} />
|
| 70 |
+
</button>
|
| 71 |
+
|
| 72 |
+
<div className="glass-panel animate-slide-up" style={{
|
| 73 |
+
maxWidth: '900px', width: '100%', padding: '0', display: 'flex',
|
| 74 |
+
overflow: 'hidden', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)'
|
| 75 |
+
}}>
|
| 76 |
+
|
| 77 |
+
{/* Lado Izquierdo: Selección de Rol */}
|
| 78 |
+
<div style={{
|
| 79 |
+
width: '35%', background: 'rgba(0,0,0,0.02)',
|
| 80 |
+
borderRight: '1px solid var(--border-subtle)', padding: '40px 30px',
|
| 81 |
+
display: 'flex', flexDirection: 'column', gap: '20px'
|
| 82 |
+
}}>
|
| 83 |
+
<h2 style={{ fontSize: '1.4rem', fontWeight: '800', marginBottom: '10px' }}>Bienvenido</h2>
|
| 84 |
+
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: '20px' }}>Seleccione su rol para ingresar al sistema</p>
|
| 85 |
+
|
| 86 |
+
<button
|
| 87 |
+
onClick={() => { setDemo('admin'); setError(''); }}
|
| 88 |
+
style={{
|
| 89 |
+
padding: '20px', borderRadius: 'var(--radius-sm)', border: '1px solid',
|
| 90 |
+
borderColor: loginMode === 'admin' ? 'var(--primary)' : 'transparent',
|
| 91 |
+
background: loginMode === 'admin' ? 'rgba(255,107,107,0.1)' : 'rgba(255,255,255,0.02)',
|
| 92 |
+
display: 'flex', alignItems: 'center', gap: '15px', textAlign: 'left',
|
| 93 |
+
transition: 'all 0.3s ease', cursor: 'pointer'
|
| 94 |
+
}}
|
| 95 |
+
>
|
| 96 |
+
<div style={{
|
| 97 |
+
padding: '10px', borderRadius: '10px',
|
| 98 |
+
background: loginMode === 'admin' ? 'var(--primary)' : 'rgba(255,255,255,0.05)',
|
| 99 |
+
color: loginMode === 'admin' ? '#fff' : 'var(--text-muted)'
|
| 100 |
+
}}>
|
| 101 |
+
<ShieldCheck size={24} />
|
| 102 |
+
</div>
|
| 103 |
+
<div>
|
| 104 |
+
<div style={{ fontWeight: '700', color: loginMode === 'admin' ? 'var(--text-main)' : 'var(--text-muted)' }}>Administrador</div>
|
| 105 |
+
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>Gestión y Reportes</div>
|
| 106 |
+
</div>
|
| 107 |
+
</button>
|
| 108 |
+
|
| 109 |
+
<button
|
| 110 |
+
onClick={() => { setDemo('mesero'); setError(''); }}
|
| 111 |
+
style={{
|
| 112 |
+
padding: '20px', borderRadius: 'var(--radius-sm)', border: '1px solid',
|
| 113 |
+
borderColor: loginMode === 'mesero' ? 'var(--primary)' : 'transparent',
|
| 114 |
+
background: loginMode === 'mesero' ? 'rgba(255,107,107,0.1)' : 'rgba(255,255,255,0.02)',
|
| 115 |
+
display: 'flex', alignItems: 'center', gap: '15px', textAlign: 'left',
|
| 116 |
+
transition: 'all 0.3s ease', cursor: 'pointer'
|
| 117 |
+
}}
|
| 118 |
+
>
|
| 119 |
+
<div style={{
|
| 120 |
+
padding: '10px', borderRadius: '10px',
|
| 121 |
+
background: loginMode === 'mesero' ? 'var(--primary)' : 'rgba(255,255,255,0.05)',
|
| 122 |
+
color: loginMode === 'mesero' ? '#fff' : 'var(--text-muted)'
|
| 123 |
+
}}>
|
| 124 |
+
<User size={24} />
|
| 125 |
+
</div>
|
| 126 |
+
<div>
|
| 127 |
+
<div style={{ fontWeight: '700', color: loginMode === 'mesero' ? 'var(--text-main)' : 'var(--text-muted)' }}>Mesero / POS</div>
|
| 128 |
+
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>Pedidos y Control</div>
|
| 129 |
+
</div>
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
{/* Lado Derecho: Formulario de Login */}
|
| 134 |
+
<div style={{ flex: 1, padding: '60px' }}>
|
| 135 |
+
<div style={{ marginBottom: '40px' }}>
|
| 136 |
+
<h1 className="text-gradient" style={{ fontSize: '2.5rem', fontWeight: '900', marginBottom: '10px' }}>
|
| 137 |
+
{loginMode === 'admin' ? 'Dashboard Admin' : 'Terminal Meseros'}
|
| 138 |
+
</h1>
|
| 139 |
+
<p style={{ color: 'var(--text-muted)' }}>Ingrese sus credenciales para continuar</p>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
{error && (
|
| 143 |
+
<div className="glass-card" style={{ padding: '10px 20px', background: 'rgba(255,90,95,0.1)', border: '1px solid var(--primary)', borderRadius: 'var(--radius-sm)', marginBottom: '20px', color: 'var(--primary)', fontSize: '0.9rem' }}>
|
| 144 |
+
{error}
|
| 145 |
+
</div>
|
| 146 |
+
)}
|
| 147 |
+
|
| 148 |
+
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
| 149 |
+
<div>
|
| 150 |
+
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9rem', color: 'var(--text-muted)' }}>Correo Electrónico</label>
|
| 151 |
+
<input
|
| 152 |
+
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
|
| 153 |
+
style={{ width: '100%', padding: '14px', borderRadius: '10px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)', color: 'var(--text-main)' }}
|
| 154 |
+
/>
|
| 155 |
+
</div>
|
| 156 |
+
<div>
|
| 157 |
+
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9rem', color: 'var(--text-muted)' }}>Contraseña</label>
|
| 158 |
+
<input
|
| 159 |
+
type="password" required value={password} onChange={(e) => setPassword(e.target.value)}
|
| 160 |
+
style={{ width: '100%', padding: '14px', borderRadius: '10px', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)', color: 'var(--text-main)' }}
|
| 161 |
+
/>
|
| 162 |
+
</div>
|
| 163 |
+
<button type="submit" className="btn-primary" style={{ height: '55px', fontSize: '1.1rem', marginTop: '10px' }} disabled={loading}>
|
| 164 |
+
{loading ? 'Validando...' : 'Iniciar Sesión'}
|
| 165 |
+
</button>
|
| 166 |
+
</form>
|
| 167 |
+
|
| 168 |
+
{/* Botón Demo rápido */}
|
| 169 |
+
<div style={{ marginTop: '30px', textAlign: 'center' }}>
|
| 170 |
+
<button
|
| 171 |
+
onClick={() => setDemo(loginMode)}
|
| 172 |
+
style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', textDecoration: 'underline', fontSize: '0.85rem' }}
|
| 173 |
+
>
|
| 174 |
+
Usar cuenta de prueba para {loginMode === 'admin' ? 'Admin' : 'Mesero'}
|
| 175 |
+
</button>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
);
|
| 181 |
+
}
|
frontend/src/pages/WaiterPOS.jsx
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '../context/AuthContext';
|
| 3 |
+
import { useNavigate } from 'react-router-dom';
|
| 4 |
+
import { db } from '../firebase/config';
|
| 5 |
+
import { ref, onValue, push, set } from 'firebase/database';
|
| 6 |
+
import { LogOut, Coffee, Send, Trash2, ShoppingBag, Truck, Receipt, CheckCircle, XCircle, CreditCard, Banknote, QrCode } from 'lucide-react';
|
| 7 |
+
|
| 8 |
+
export default function WaiterPOS() {
|
| 9 |
+
const { logout, currentUser } = useAuth();
|
| 10 |
+
const navigate = useNavigate();
|
| 11 |
+
const [menu, setMenu] = useState([]);
|
| 12 |
+
const [activeTable, setActiveTable] = useState(null);
|
| 13 |
+
const [cart, setCart] = useState([]);
|
| 14 |
+
const [orderType, setOrderType] = useState('Mesa'); // Mesa, Llevar, Delivery
|
| 15 |
+
const [discount, setDiscount] = useState(0);
|
| 16 |
+
const [tip, setTip] = useState(0);
|
| 17 |
+
const [paymentMethod, setPaymentMethod] = useState('Efectivo');
|
| 18 |
+
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
| 19 |
+
const [customerData, setCustomerData] = useState({ name: '', email: '', phone: '', address: '' });
|
| 20 |
+
|
| 21 |
+
// Fetch Menu
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
onValue(ref(db, 'menu'), (snapshot) => {
|
| 24 |
+
const data = snapshot.val();
|
| 25 |
+
if (data) {
|
| 26 |
+
setMenu(Object.keys(data).map(k => ({ id: k, ...data[k] })));
|
| 27 |
+
}
|
| 28 |
+
});
|
| 29 |
+
}, []);
|
| 30 |
+
|
| 31 |
+
const handleLogout = async () => {
|
| 32 |
+
await logout();
|
| 33 |
+
navigate('/login');
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const addToCart = (product) => {
|
| 37 |
+
if (!activeTable && orderType === 'Mesa') {
|
| 38 |
+
alert("Selecciona una mesa primero.");
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
const existing = cart.find(item => item.id === product.id);
|
| 42 |
+
if (existing) {
|
| 43 |
+
setCart(cart.map(item => item.id === product.id ? { ...item, qty: item.qty + 1 } : item));
|
| 44 |
+
} else {
|
| 45 |
+
setCart([...cart, { ...product, qty: 1 }]);
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const removeFromCart = (id) => {
|
| 50 |
+
setCart(cart.filter(item => item.id !== id));
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const subtotal = cart.reduce((acc, item) => acc + (item.price * item.qty), 0);
|
| 54 |
+
const discountAmount = (subtotal * discount) / 100;
|
| 55 |
+
const totalCart = subtotal - discountAmount + Number(tip || 0);
|
| 56 |
+
|
| 57 |
+
const sendOrder = async () => {
|
| 58 |
+
if ((orderType === 'Mesa' && !activeTable) || cart.length === 0) return;
|
| 59 |
+
const orderRef = push(ref(db, 'orders'));
|
| 60 |
+
const orderData = {
|
| 61 |
+
table: orderType === 'Mesa' ? activeTable : orderType,
|
| 62 |
+
type: orderType,
|
| 63 |
+
waiter: currentUser?.email,
|
| 64 |
+
customerName: customerData.name || 'Consumidor Final',
|
| 65 |
+
customerEmail: customerData.email || 'anonimo@rest.os',
|
| 66 |
+
address: customerData.address || '',
|
| 67 |
+
items: cart,
|
| 68 |
+
subtotal,
|
| 69 |
+
discount,
|
| 70 |
+
tip,
|
| 71 |
+
total: totalCart,
|
| 72 |
+
paymentMethod: paymentMethod,
|
| 73 |
+
status: 'pending',
|
| 74 |
+
timestamp: Date.now()
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
await set(orderRef, orderData);
|
| 78 |
+
|
| 79 |
+
if (!isCheckoutOpen) {
|
| 80 |
+
setCart([]);
|
| 81 |
+
setActiveTable(null);
|
| 82 |
+
setCustomerData({ name: '', email: '', phone: '', address: '' });
|
| 83 |
+
alert('¡Comanda enviada a cocina exitosamente!');
|
| 84 |
+
} else {
|
| 85 |
+
// Finalizar y descontar stock
|
| 86 |
+
await set(ref(db, `orders/${orderRef.key}/status`), 'completed');
|
| 87 |
+
|
| 88 |
+
// Lógica de Descuento de Inventario
|
| 89 |
+
for (const item of cart) {
|
| 90 |
+
const productRef = ref(db, `menu/${item.id}`);
|
| 91 |
+
onValue(productRef, async (snapshot) => {
|
| 92 |
+
const product = snapshot.val();
|
| 93 |
+
if (product && product.ingredients) {
|
| 94 |
+
for (const ing of product.ingredients) {
|
| 95 |
+
onValue(ref(db, `inventory/${ing.id}`), async (invSnap) => {
|
| 96 |
+
const invData = invSnap.val();
|
| 97 |
+
if (invData) {
|
| 98 |
+
const newQty = invData.quantity - (item.qty * ing.qty);
|
| 99 |
+
await update(ref(db, `inventory/${ing.id}`), { quantity: newQty });
|
| 100 |
+
}
|
| 101 |
+
}, { onlyOnce: true });
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
}, { onlyOnce: true });
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
setIsCheckoutOpen(false);
|
| 108 |
+
setCart([]);
|
| 109 |
+
setActiveTable(null);
|
| 110 |
+
setCustomerData({ name: '', email: '', phone: '', address: '' });
|
| 111 |
+
alert('¡Venta realizada y stock actualizado!');
|
| 112 |
+
}
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const printTicket = () => {
|
| 116 |
+
const printWindow = window.open('', '_blank');
|
| 117 |
+
printWindow.document.write(`
|
| 118 |
+
<html>
|
| 119 |
+
<head><title>Ticket de Venta</title></head>
|
| 120 |
+
<body style="font-family: monospace; width: 300px; padding: 20px;">
|
| 121 |
+
<h2 style="text-align: center;">RESTAURANT OS</h2>
|
| 122 |
+
<hr/>
|
| 123 |
+
<p>Mesa: ${activeTable || orderType}</p>
|
| 124 |
+
<p>Fecha: ${new Date().toLocaleString()}</p>
|
| 125 |
+
<hr/>
|
| 126 |
+
${cart.map(item => `<p>${item.qty}x ${item.name.padEnd(20)} $${(item.price * item.qty).toFixed(2)}</p>`).join('')}
|
| 127 |
+
<hr/>
|
| 128 |
+
<p>Subtotal: $${subtotal.toFixed(2)}</p>
|
| 129 |
+
<p>Descuento (${discount}%): -$${discountAmount.toFixed(2)}</p>
|
| 130 |
+
<p>Propina: $${Number(tip || 0).toFixed(2)}</p>
|
| 131 |
+
<h3 style="text-align: right;">Total: $${totalCart.toFixed(2)}</h3>
|
| 132 |
+
<p style="text-align: center;">¡Gracias por su visita!</p>
|
| 133 |
+
</body>
|
| 134 |
+
</html>
|
| 135 |
+
`);
|
| 136 |
+
printWindow.document.close();
|
| 137 |
+
printWindow.print();
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
return (
|
| 141 |
+
<div className="app-container" style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
|
| 142 |
+
{/* Navbar */}
|
| 143 |
+
<header className="glass-panel" style={{ padding: '1rem 2rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderRadius: '0', borderBottom: '1px solid var(--border-subtle)', background: 'var(--bg-base)' }}>
|
| 144 |
+
<h1 className="text-gradient" style={{ fontSize: '1.5rem', fontWeight: '800' }}>RESTO PLUS POS</h1>
|
| 145 |
+
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
| 146 |
+
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{currentUser?.email}</span>
|
| 147 |
+
<button className="btn-glass" onClick={() => window.open('/menu', '_blank')} style={{ padding: '0.5rem 1rem' }}>Carta Digital</button>
|
| 148 |
+
<button className="btn-glass" onClick={() => window.open('/kitchen', '_blank')} style={{ padding: '0.5rem 1rem' }}>Cocina</button>
|
| 149 |
+
<button className="btn-glass" onClick={handleLogout} style={{ padding: '0.5rem 1rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
| 150 |
+
<LogOut size={16} /> Salir
|
| 151 |
+
</button>
|
| 152 |
+
</div>
|
| 153 |
+
</header>
|
| 154 |
+
|
| 155 |
+
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
| 156 |
+
{/* Left Column: Tables & Menu */}
|
| 157 |
+
<div style={{ flex: '65%', padding: '1.5rem', overflowY: 'auto', background: 'rgba(255,255,255,0.01)' }}>
|
| 158 |
+
|
| 159 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
| 160 |
+
<h2 className="text-gradient" style={{ fontSize: '1.25rem' }}>Tipo de Pedido</h2>
|
| 161 |
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
| 162 |
+
{[
|
| 163 |
+
{ id: 'Mesa', icon: <Coffee size={18} /> },
|
| 164 |
+
{ id: 'Llevar', icon: <ShoppingBag size={18} /> },
|
| 165 |
+
{ id: 'Delivery', icon: <Truck size={18} /> }
|
| 166 |
+
].map(type => (
|
| 167 |
+
<button
|
| 168 |
+
key={type.id}
|
| 169 |
+
onClick={() => { setOrderType(type.id); if(type.id !== 'Mesa') setActiveTable(null); }}
|
| 170 |
+
className={orderType === type.id ? 'btn-primary' : 'btn-glass'}
|
| 171 |
+
style={{ padding: '0.5rem 1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
| 172 |
+
>
|
| 173 |
+
{type.icon} {type.id}
|
| 174 |
+
</button>
|
| 175 |
+
))}
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div className="glass-card" style={{ padding: '1.25rem', marginBottom: '1.5rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem' }}>
|
| 180 |
+
<div style={{ gridColumn: orderType === 'Delivery' ? 'span 2' : 'auto' }}>
|
| 181 |
+
<label style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>Cliente / Mesa</label>
|
| 182 |
+
<input
|
| 183 |
+
type="text" placeholder="Nombre del Cliente"
|
| 184 |
+
value={customerData.name} onChange={e => setCustomerData({...customerData, name: e.target.value})}
|
| 185 |
+
style={{ ...inputStyle, padding: '0.6rem' }}
|
| 186 |
+
/>
|
| 187 |
+
</div>
|
| 188 |
+
{orderType === 'Delivery' && (
|
| 189 |
+
<div style={{ gridColumn: 'span 2' }}>
|
| 190 |
+
<label style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>Dirección de Entrega</label>
|
| 191 |
+
<input
|
| 192 |
+
type="text" placeholder="Calle, Número, Referencias"
|
| 193 |
+
value={customerData.address} onChange={e => setCustomerData({...customerData, address: e.target.value})}
|
| 194 |
+
style={{ ...inputStyle, padding: '0.6rem' }}
|
| 195 |
+
/>
|
| 196 |
+
</div>
|
| 197 |
+
)}
|
| 198 |
+
<div>
|
| 199 |
+
<label style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>Email (Puntos)</label>
|
| 200 |
+
<input
|
| 201 |
+
type="email" placeholder="cliente@email.com"
|
| 202 |
+
value={customerData.email} onChange={e => setCustomerData({...customerData, email: e.target.value})}
|
| 203 |
+
style={{ ...inputStyle, padding: '0.6rem' }}
|
| 204 |
+
/>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
{orderType === 'Mesa' && (
|
| 209 |
+
<div style={{ display: 'flex', gap: '1rem', overflowX: 'auto', paddingBottom: '1rem', marginBottom: '1.5rem' }}>
|
| 210 |
+
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map(table => (
|
| 211 |
+
<button
|
| 212 |
+
key={table}
|
| 213 |
+
onClick={() => setActiveTable(table)}
|
| 214 |
+
style={{
|
| 215 |
+
minWidth: '70px', height: '70px', borderRadius: 'var(--radius-lg)',
|
| 216 |
+
background: activeTable === table ? 'var(--primary)' : 'rgba(0,0,0,0.03)',
|
| 217 |
+
border: activeTable === table ? '2px solid transparent' : '1px solid var(--border-subtle)',
|
| 218 |
+
color: activeTable === table ? '#fff' : 'var(--text-main)',
|
| 219 |
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
| 220 |
+
fontWeight: '600', transition: 'all 0.2s', cursor: 'pointer',
|
| 221 |
+
boxShadow: activeTable === table ? '0 4px 12px var(--primary-glow)' : 'none'
|
| 222 |
+
}}
|
| 223 |
+
>
|
| 224 |
+
{table}
|
| 225 |
+
</button>
|
| 226 |
+
))}
|
| 227 |
+
</div>
|
| 228 |
+
)}
|
| 229 |
+
|
| 230 |
+
<h2 className="text-gradient" style={{ fontSize: '1.25rem', marginBottom: '1.5rem' }}>Catálogo de Productos</h2>
|
| 231 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: '1rem' }}>
|
| 232 |
+
{menu.map(product => (
|
| 233 |
+
<button
|
| 234 |
+
key={product.id}
|
| 235 |
+
onClick={() => addToCart(product)}
|
| 236 |
+
className="glass-card"
|
| 237 |
+
style={{ textAlign: 'left', padding: '1rem', borderColor: 'var(--border-subtle)', position: 'relative' }}
|
| 238 |
+
>
|
| 239 |
+
<p style={{ fontWeight: '600', marginBottom: '0.5rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
| 240 |
+
{product.name}
|
| 241 |
+
</p>
|
| 242 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 243 |
+
<span style={{ color: 'var(--success)', fontWeight: '700' }}>${Number(product.price).toFixed(2)}</span>
|
| 244 |
+
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{product.category}</span>
|
| 245 |
+
</div>
|
| 246 |
+
</button>
|
| 247 |
+
))}
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
{/* Right Column: Order Details */}
|
| 252 |
+
<div style={{ flex: '35%', background: 'var(--bg-card)', borderLeft: '1px solid var(--border-subtle)', display: 'flex', flexDirection: 'column' }}>
|
| 253 |
+
<div style={{ padding: '1.5rem', borderBottom: '1px solid var(--border-subtle)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 254 |
+
<h2 className="text-gradient" style={{ fontSize: '1.25rem' }}>
|
| 255 |
+
{orderType === 'Mesa' ? `Mesa ${activeTable || '?'}` : orderType}
|
| 256 |
+
</h2>
|
| 257 |
+
<button onClick={() => setCart([])} style={{ color: 'var(--danger)', fontSize: '0.8rem', background: 'none', border: 'none', cursor: 'not-allowed' }} disabled={cart.length === 0}>
|
| 258 |
+
Limpiar
|
| 259 |
+
</button>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
| 263 |
+
{cart.map((item) => (
|
| 264 |
+
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem', background: 'rgba(0,0,0,0.02)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-subtle)' }}>
|
| 265 |
+
<div style={{ flex: 1 }}>
|
| 266 |
+
<div style={{ fontWeight: '600', fontSize: '0.95rem' }}>{item.name}</div>
|
| 267 |
+
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{item.qty} x ${item.price}</div>
|
| 268 |
+
</div>
|
| 269 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
| 270 |
+
<span style={{ fontWeight: '700' }}>${(item.qty * item.price).toFixed(2)}</span>
|
| 271 |
+
<button onClick={() => removeFromCart(item.id)} style={{ color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer' }}>
|
| 272 |
+
<Trash2 size={16} />
|
| 273 |
+
</button>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
))}
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<div style={{ padding: '1.5rem', borderTop: '1px solid var(--border-subtle)', background: 'rgba(0,0,0,0.03)' }}>
|
| 280 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
| 281 |
+
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
| 282 |
+
<span>Subtotal:</span>
|
| 283 |
+
<span>${subtotal.toFixed(2)}</span>
|
| 284 |
+
</div>
|
| 285 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', color: 'var(--primary)' }}>
|
| 286 |
+
<span>Descuento ({discount}%):</span>
|
| 287 |
+
<span>-${discountAmount.toFixed(2)}</span>
|
| 288 |
+
</div>
|
| 289 |
+
{tip > 0 && (
|
| 290 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', color: 'var(--success)' }}>
|
| 291 |
+
<span>Propina:</span>
|
| 292 |
+
<span>+${Number(tip).toFixed(2)}</span>
|
| 293 |
+
</div>
|
| 294 |
+
)}
|
| 295 |
+
</div>
|
| 296 |
+
|
| 297 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '1.4rem', fontWeight: '900', marginBottom: '1.5rem' }}>
|
| 298 |
+
<span>Total:</span>
|
| 299 |
+
<span className="text-gradient-primary">${totalCart.toFixed(2)}</span>
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
| 303 |
+
<button
|
| 304 |
+
className="btn-glass"
|
| 305 |
+
onClick={sendOrder}
|
| 306 |
+
disabled={cart.length === 0 || (orderType === 'Mesa' && !activeTable)}
|
| 307 |
+
style={{ opacity: (cart.length === 0) ? 0.5 : 1, padding: '0.8rem' }}
|
| 308 |
+
>
|
| 309 |
+
<Send size={18} /> Comanda
|
| 310 |
+
</button>
|
| 311 |
+
<button
|
| 312 |
+
className="btn-primary"
|
| 313 |
+
onClick={() => setIsCheckoutOpen(true)}
|
| 314 |
+
disabled={cart.length === 0}
|
| 315 |
+
style={{ opacity: (cart.length === 0) ? 0.5 : 1, padding: '0.8rem' }}
|
| 316 |
+
>
|
| 317 |
+
<Receipt size={18} /> Pagar
|
| 318 |
+
</button>
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
|
| 324 |
+
{/* Checkout Modal */}
|
| 325 |
+
{isCheckoutOpen && (
|
| 326 |
+
<div style={{
|
| 327 |
+
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
| 328 |
+
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(10px)',
|
| 329 |
+
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
|
| 330 |
+
}}>
|
| 331 |
+
<div className="glass-panel animate-scale-in" style={{ width: '100%', maxWidth: '500px', padding: '2.5rem' }}>
|
| 332 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
| 333 |
+
<h2 className="text-gradient" style={{ fontSize: '1.75rem' }}>Finalizar Venta</h2>
|
| 334 |
+
<button onClick={() => setIsCheckoutOpen(false)} style={{ background: 'none', border: 'none', color: 'var(--text-muted)' }}>
|
| 335 |
+
<XCircle size={28} />
|
| 336 |
+
</button>
|
| 337 |
+
</div>
|
| 338 |
+
|
| 339 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
| 340 |
+
<div>
|
| 341 |
+
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Descuento (%)</label>
|
| 342 |
+
<select
|
| 343 |
+
value={discount}
|
| 344 |
+
onChange={(e) => setDiscount(Number(e.target.value))}
|
| 345 |
+
style={{ width: '100%', padding: '0.8rem', background: 'rgba(255,255,255,0.05)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', color: '#fff' }}
|
| 346 |
+
>
|
| 347 |
+
<option value="0">Sin Descuento</option>
|
| 348 |
+
<option value="5">5% Promo</option>
|
| 349 |
+
<option value="10">10% Cortesía</option>
|
| 350 |
+
<option value="15">15% Empleado</option>
|
| 351 |
+
<option value="50">50% VIP</option>
|
| 352 |
+
</select>
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<div>
|
| 356 |
+
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Propina (Efectivo/Sugerida)</label>
|
| 357 |
+
<input
|
| 358 |
+
type="number"
|
| 359 |
+
placeholder="$ 0.00"
|
| 360 |
+
value={tip}
|
| 361 |
+
onChange={(e) => setTip(e.target.value)}
|
| 362 |
+
style={{ width: '100%', padding: '0.8rem', background: 'rgba(0,0,0,0.03)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', color: 'var(--text-main)' }}
|
| 363 |
+
/>
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<div>
|
| 367 |
+
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Método de Pago</label>
|
| 368 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem' }}>
|
| 369 |
+
{[
|
| 370 |
+
{ id: 'Efectivo', icon: <Banknote size={20} /> },
|
| 371 |
+
{ id: 'Tarjeta', icon: <CreditCard size={20} /> },
|
| 372 |
+
{ id: 'QR', icon: <QrCode size={20} /> }
|
| 373 |
+
].map(m => (
|
| 374 |
+
<button
|
| 375 |
+
key={m.id}
|
| 376 |
+
onClick={() => setPaymentMethod(m.id)}
|
| 377 |
+
className={paymentMethod === m.id ? 'btn-primary' : 'btn-glass'}
|
| 378 |
+
style={{ padding: '1rem 0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', fontSize: '0.8rem' }}
|
| 379 |
+
>
|
| 380 |
+
{m.icon} {m.id}
|
| 381 |
+
</button>
|
| 382 |
+
))}
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
|
| 386 |
+
<div style={{ background: 'rgba(255,255,255,0.02)', padding: '1.5rem', borderRadius: 'var(--radius-lg)', marginTop: '1rem', border: '1px dashed var(--border-subtle)' }}>
|
| 387 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '1.5rem', fontWeight: '900' }}>
|
| 388 |
+
<span>Total a Pagar:</span>
|
| 389 |
+
<span className="text-gradient">${totalCart.toFixed(2)}</span>
|
| 390 |
+
</div>
|
| 391 |
+
</div>
|
| 392 |
+
|
| 393 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem' }}>
|
| 394 |
+
<button className="btn-glass" onClick={printTicket} style={{ padding: '1rem' }}>
|
| 395 |
+
<Receipt size={20} /> Preview Ticket
|
| 396 |
+
</button>
|
| 397 |
+
<button className="btn-primary" onClick={sendOrder} style={{ padding: '1rem' }}>
|
| 398 |
+
<CheckCircle size={20} /> Confirmar Pago
|
| 399 |
+
</button>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
</div>
|
| 404 |
+
)}
|
| 405 |
+
|
| 406 |
+
<style>{`
|
| 407 |
+
.btn-glass {
|
| 408 |
+
background: rgba(0,0,0,0.03);
|
| 409 |
+
border: 1px solid var(--border-subtle);
|
| 410 |
+
color: var(--text-main);
|
| 411 |
+
border-radius: var(--radius-md);
|
| 412 |
+
cursor: pointer;
|
| 413 |
+
font-weight: 600;
|
| 414 |
+
transition: all 0.2s;
|
| 415 |
+
display: flex;
|
| 416 |
+
align-items: center;
|
| 417 |
+
justify-content: center;
|
| 418 |
+
gap: 0.5rem;
|
| 419 |
+
}
|
| 420 |
+
.btn-glass:hover {
|
| 421 |
+
background: rgba(0,0,0,0.06);
|
| 422 |
+
transform: translateY(-2px);
|
| 423 |
+
}
|
| 424 |
+
.btn-primary {
|
| 425 |
+
background: var(--primary);
|
| 426 |
+
color: white;
|
| 427 |
+
border: none;
|
| 428 |
+
border-radius: var(--radius-md);
|
| 429 |
+
cursor: pointer;
|
| 430 |
+
font-weight: 600;
|
| 431 |
+
transition: all 0.2s;
|
| 432 |
+
display: flex;
|
| 433 |
+
align-items: center;
|
| 434 |
+
justify-content: center;
|
| 435 |
+
gap: 0.5rem;
|
| 436 |
+
box-shadow: 0 4px 12px var(--primary-glow);
|
| 437 |
+
}
|
| 438 |
+
.btn-primary:hover {
|
| 439 |
+
opacity: 0.9;
|
| 440 |
+
transform: translateY(-2px);
|
| 441 |
+
box-shadow: 0 6px 16px var(--primary-glow);
|
| 442 |
+
}
|
| 443 |
+
.animate-scale-in {
|
| 444 |
+
animation: scaleIn 0.3s ease-out;
|
| 445 |
+
}
|
| 446 |
+
@keyframes scaleIn {
|
| 447 |
+
from { transform: scale(0.9); opacity: 0; }
|
| 448 |
+
to { transform: scale(1); opacity: 1; }
|
| 449 |
+
}
|
| 450 |
+
`}</style>
|
| 451 |
+
</div>
|
| 452 |
+
);
|
| 453 |
+
}
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
// https://vite.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
})
|
package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"chart.js": "^4.5.1",
|
| 14 |
+
"firebase": "^12.11.0",
|
| 15 |
+
"lucide-react": "^0.577.0",
|
| 16 |
+
"react": "^19.2.4",
|
| 17 |
+
"react-chartjs-2": "^5.3.1",
|
| 18 |
+
"react-dom": "^19.2.4",
|
| 19 |
+
"react-router-dom": "^7.13.1"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@eslint/js": "^9.39.4",
|
| 23 |
+
"@types/react": "^19.2.14",
|
| 24 |
+
"@types/react-dom": "^19.2.3",
|
| 25 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 26 |
+
"eslint": "^9.39.4",
|
| 27 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 28 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 29 |
+
"globals": "^17.4.0",
|
| 30 |
+
"vite": "^6.0.0"
|
| 31 |
+
}
|
| 32 |
+
}
|