Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

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

8
frontend/.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
frontend/README.md Normal file
View File

@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

67
frontend/fix-ts.sh Normal file
View File

@@ -0,0 +1,67 @@
#!/bin/bash
cd /root/skeen-frontend
# Fix all files: use type-only import and remove unused variables
for f in src/App.tsx src/pages/*.tsx; do
# Fix import
sed -i "s/import { FC } from 'react';/import type { FC } from 'react';/g" "$f"
sed -i "s/import { FC, useState } from 'react';/import type { FC } from 'react';\nimport { useState } from 'react';/g" "$f"
done
# Fix Agenda: remove unused selectedDate
cat > src/pages/Agenda.tsx << 'AGENDA'
import type { FC } from 'react';
const Agenda: FC = () => {
// Datos de ejemplo
const citas = [
{ id: 1, hora: '09:00', paciente: 'Sofia Garcia', servicio: 'Consulta Dermatologica', estado: 'confirmada' },
{ id: 2, hora: '10:00', paciente: 'Maria Lopez', servicio: 'Tratamiento Facial Acne', estado: 'confirmada' },
{ id: 3, hora: '11:30', paciente: 'Ana Martinez', servicio: 'Peel Quimico', estado: 'pendiente' },
{ id: 4, hora: '14:00', paciente: 'Carmen Ruiz', servicio: 'Depilacion Laser', estado: 'confirmada' },
{ id: 5, hora: '16:00', paciente: 'Laura Hernandez', servicio: 'Consulta de Valoracion', estado: 'confirmada' },
];
const getEstadoColor = (estado: string) => {
switch (estado) {
case 'confirmada': return 'bg-green-100 text-green-800';
case 'pendiente': return 'bg-yellow-100 text-yellow-800';
case 'completada': return 'bg-blue-100 text-blue-800';
case 'cancelada': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
return (
<div>
<h1 className="text-3xl font-bold text-gray-800 mb-8">Agenda</h1>
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">Citas del Dia</h2>
<button className="bg-skeen-primary text-white px-4 py-2 rounded hover:bg-skeen-secondary transition">+ Nueva Cita</button>
</div>
</div>
<div className="divide-y">
{citas.map((cita) => (
<div key={cita.id} className="p-6 flex items-center justify-between hover:bg-gray-50">
<div className="flex items-center space-x-4">
<div className="text-lg font-semibold text-gray-700 w-16">{cita.hora}</div>
<div>
<p className="font-medium text-gray-800">{cita.paciente}</p>
<p className="text-sm text-gray-600">{cita.servicio}</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getEstadoColor(cita.estado)}`}>{cita.estado}</span>
</div>
))}
</div>
</div>
</div>
);
};
export default Agenda;
AGENDA
echo "Fixed TypeScript files"

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Alike&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SKEEN Derma Experts</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3166
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
frontend/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "skeen-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/lato": "^5.2.7",
"@fontsource/playfair-display": "^5.2.8",
"@tailwindcss/vite": "^4.3.2",
"autoprefixer": "^10.5.2",
"axios": "^1.18.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.23.0",
"moment": "^2.30.1",
"postcss": "^8.5.16",
"react": "^19.2.7",
"react-big-calendar": "^1.20.0",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"recharts": "^3.9.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"happy-dom": "^20.10.6",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

5
frontend/src/App.css Normal file
View File

@@ -0,0 +1,5 @@
#root {
width: 100%;
max-width: 100%;
min-height: 100svh;
}

77
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,77 @@
import type { FC, ReactNode } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ToastProvider } from './components/ui';
import { AuthProvider, useAuth } from './lib/auth';
import Dashboard from './pages/Dashboard';
import Agenda from './pages/Agenda';
import Pacientes from './pages/Pacientes';
import Servicios from './pages/Servicios';
import Medicos from './pages/Medicos';
import Productos from './pages/Productos';
import Monedero from './pages/Monedero';
import Pagos from './pages/Pagos';
import Ventas from './pages/Ventas';
import Cortes from './pages/Cortes';
import Reportes from './pages/Reportes';
import Configuracion from './pages/Configuracion';
import Cumpleanos from './pages/Cumpleanos';
import Inventario from './pages/Inventario';
import Usuarios from './pages/Usuarios';
import WacrmMessages from './pages/WacrmMessages';
import WacrmLeads from './pages/WacrmLeads';
import Login from './pages/Login';
import './App.css';
const FullScreenLoader: FC = () => (
<div className="min-h-screen bg-[#f8f6f4] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[#E5E0DC] border-t-[#1A1A1A] rounded-full animate-spin" />
</div>
);
const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href }) => {
const { user, loading, canSee } = useAuth();
if (loading) return <FullScreenLoader />;
if (!user) return <Navigate to="/login" replace />;
if (!canSee(href)) return <Navigate to="/" replace />;
return <>{children}</>;
};
const LoginRoute: FC = () => {
const { user, loading } = useAuth();
if (loading) return <FullScreenLoader />;
if (user) return <Navigate to="/" replace />;
return <Login />;
};
const App: FC = () => {
return (
<AuthProvider>
<Router>
<ToastProvider />
<Routes>
<Route path="/login" element={<LoginRoute />} />
<Route path="/" element={<RequireAuth href="/"><Dashboard /></RequireAuth>} />
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
<Route path="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
<Route path="/productos" element={<RequireAuth href="/productos"><Productos /></RequireAuth>} />
<Route path="/monedero" element={<RequireAuth href="/monedero"><Monedero /></RequireAuth>} />
<Route path="/pagos" element={<RequireAuth href="/pagos"><Pagos /></RequireAuth>} />
<Route path="/ventas" element={<RequireAuth href="/ventas"><Ventas /></RequireAuth>} />
<Route path="/cortes" element={<RequireAuth href="/cortes"><Cortes /></RequireAuth>} />
<Route path="/reportes" element={<RequireAuth href="/reportes"><Reportes /></RequireAuth>} />
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
<Route path="/usuarios" element={<RequireAuth href="/usuarios"><Usuarios /></RequireAuth>} />
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
</AuthProvider>
);
};
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,475 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Calendar,
Users,
Stethoscope,
Briefcase,
Package,
Wallet,
CreditCard,
ShoppingCart,
Receipt,
BarChart3,
Settings,
Bell,
Search,
Menu,
X,
ChevronDown,
LogOut,
MessageSquare,
Target,
Cake,
Boxes,
ShieldCheck,
Sparkles,
Palette,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import { useAuth } from '../lib/auth';
import { useTheme } from '../lib/theme';
import { odooApi } from '../services/odoo';
interface LayoutProps {
children: ReactNode;
title: string;
subtitle?: string;
}
const LOGO_HOMENEST = '/skeen-brand/logos/Logo%20Completo%20Negro.png';
const topNavItems = [
{ label: 'Dashboard', icon: LayoutDashboard, href: '/' },
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
];
const menuSections = [
{
label: 'Clínica',
items: [
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
{ label: 'Productos', icon: Package, href: '/productos' },
],
},
{
label: 'Operaciones',
items: [
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
{ label: 'Pagos', icon: CreditCard, href: '/pagos' },
{ label: 'Monedero', icon: Wallet, href: '/monedero' },
{ label: 'Inventario', icon: Boxes, href: '/inventario' },
{ label: 'Cortes de Caja', icon: Receipt, href: '/cortes' },
],
},
{
label: 'Analítica y más',
items: [
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
{ label: 'Usuarios', icon: ShieldCheck, href: '/usuarios' },
],
},
{
label: 'WACRM',
items: [
{ label: 'Mensajes', icon: MessageSquare, href: '/wacrm/messages' },
{ label: 'Leads', icon: Target, href: '/wacrm/leads' },
],
},
];
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout } = useAuth();
const { toggleTheme } = useTheme();
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const visibleSections = useMemo(
() =>
menuSections
.map((s) => ({ ...s, items: s.items.filter((it) => canSee(it.href)) }))
.filter((s) => s.items.length > 0),
[canSee, user]
);
const initials = (user?.name || 'US')
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join('');
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const [goal, setGoal] = useState<{ goal: number; current: number; pct: number } | null>(null);
const [goalModalOpen, setGoalModalOpen] = useState(false);
const [goalInput, setGoalInput] = useState('');
const [goalSaving, setGoalSaving] = useState(false);
useEffect(() => {
let alive = true;
const fetchGoal = () => {
odooApi.getSalesGoal()
.then((res) => {
if (alive && res.status === 'success') {
setGoal({ goal: res.goal, current: res.current, pct: res.pct });
}
})
.catch(() => {});
};
fetchGoal();
const id = setInterval(fetchGoal, 60000);
return () => { alive = false; clearInterval(id); };
}, []);
const openGoalModal = () => {
setGoalInput(goal?.goal ? String(goal.goal) : '');
setGoalModalOpen(true);
};
const saveGoal = async () => {
const v = parseFloat(goalInput);
if (Number.isNaN(v) || v < 0) return;
try {
setGoalSaving(true);
await odooApi.setSalesGoal(v);
setGoal((g) => (g ? { ...g, goal: v, pct: g.current && v ? (g.current / v) * 100 : 0 } : g));
setGoalModalOpen(false);
} catch {
// ignore
} finally {
setGoalSaving(false);
}
};
const fmtMoney = (n: number) =>
n >= 1000000 ? `$${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `$${(n / 1000).toFixed(0)}k` : `$${n.toFixed(0)}`;
const pctClamped = Math.min(goal?.pct ?? 0, 100);
useEffect(() => {
if (!menuOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [menuOpen]);
const isActive = (href: string) => {
if (href === '/') return location.pathname === '/';
return location.pathname === href || location.pathname.startsWith(`${href}/`);
};
const toggleSection = (label: string) => {
setExpanded((prev) => (prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label]));
};
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
{/* Header horizontal */}
<header className="sticky top-0 z-30 border-b" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" className="lg:hidden" style={{ color: 'var(--text-heading)' }} onClick={() => setMenuOpen(true)} aria-label="Abrir menú">
<Menu size={22} />
</Button>
<Link to="/" className="flex items-center gap-2">
<img src={LOGO_HOMENEST} alt="SKEEN" className="h-8 w-auto object-contain" />
</Link>
</div>
<nav className="hidden lg:flex items-center gap-1">
{topNavItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.label}
to={item.href}
className="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition"
style={{
backgroundColor: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--text-inverse)' : 'var(--text-muted)',
}}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
e.currentTarget.style.color = 'var(--text-heading)';
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--text-muted)';
}
}}
>
<Icon size={16} />
{item.label}
</Link>
);
})}
<div className="relative group">
<button className="flex items-center gap-1 px-4 py-2 rounded-full text-sm font-medium transition" style={{ color: 'var(--text-muted)' }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
e.currentTarget.style.color = 'var(--text-heading)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--text-muted)';
}}
>
Más <ChevronDown size={14} />
</button>
<div className="absolute top-full left-0 mt-2 w-56 rounded-2xl shadow-card border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition z-40" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
<div className="p-2">
{visibleSections.flatMap((s) => s.items).filter((it) => !topNavItems.some((t) => t.href === it.href)).map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
to={item.href}
className="flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
style={{ color: 'var(--text-heading)' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Icon size={16} /> {item.label}
</Link>
);
})}
</div>
</div>
</div>
</nav>
<div className="flex items-center gap-2 sm:gap-3">
<div className="hidden md:flex items-center rounded-full px-3 py-1.5 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<Search size={14} style={{ color: 'var(--text-muted)' }} />
<input
type="text"
placeholder="Buscar..."
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-40 placeholder:text-theme-muted/60"
style={{ color: 'var(--text-heading)' }}
/>
</div>
<button className="p-2 rounded-full relative transition" style={{ color: 'var(--text-muted)' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Bell size={18} />
<span className="absolute top-1 right-1 w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent)' }} />
</button>
<button
onClick={toggleTheme}
title="Cambiar a SKEEN"
className="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition border"
style={{ backgroundColor: 'var(--bg)', color: 'var(--text-heading)', borderColor: 'var(--border)' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--bg)'}
>
<Palette size={14} />
SKEEN
</button>
<div className="hidden sm:flex items-center gap-2 pl-2 border-l" style={{ borderColor: 'var(--border)' }}>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
{initials || 'US'}
</div>
<button onClick={handleLogout} className="p-2 rounded-full transition" style={{ color: 'var(--text-muted)' }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
e.currentTarget.style.color = 'var(--text-heading)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--text-muted)';
}}
title="Cerrar sesión"
>
<LogOut size={16} />
</button>
</div>
</div>
</div>
</div>
</header>
<div className="flex flex-1">
{/* Sidebar workspace */}
<aside className="hidden lg:flex flex-col w-72 border-r p-5 gap-6" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
<div className="rounded-2xl p-4 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<div className="flex items-center gap-2 mb-3">
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>Meta del mes</span>
</div>
{goal && goal.goal > 0 ? (
<button onClick={hasRole('admin') ? openGoalModal : undefined} className={`w-full text-left ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}>
<div className="flex items-end justify-between mb-2">
<span className="text-2xl font-heading" style={{ color: 'var(--text-heading)' }}>{Math.round(goal.pct)}%</span>
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>{fmtMoney(goal.current)} / {fmtMoney(goal.goal)}</span>
</div>
<div className="w-full h-2 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
<div className="h-full rounded-full transition-all" style={{ width: `${pctClamped}%`, backgroundColor: goal.pct >= 100 ? 'var(--success)' : 'var(--accent)' }} />
</div>
</button>
) : (
<Button variant="outline" size="sm" className="w-full" onClick={openGoalModal}>
<Target size={14} className="mr-1.5" /> Definir meta
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto space-y-2">
{visibleSections.map((section) => (
<div key={section.label} className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<button
onClick={() => toggleSection(section.label)}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium"
style={{ color: 'var(--text-heading)' }}
>
{section.label}
<ChevronDown size={14} className={`transition-transform ${expanded.includes(section.label) ? 'rotate-180' : ''}`} style={{ color: 'var(--text-muted)' }} />
</button>
{expanded.includes(section.label) && (
<ul className="px-2 pb-2 space-y-1">
{section.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
to={item.href}
className="flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
style={{
backgroundColor: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--text-inverse)' : 'var(--text-muted)',
}}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
e.currentTarget.style.color = 'var(--text-heading)';
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--text-muted)';
}
}}
>
<Icon size={16} />
{item.label}
</Link>
</li>
);
})}
</ul>
)}
</div>
))}
</div>
</aside>
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{children}
</main>
</div>
{/* Mobile drawer */}
{menuOpen && (
<>
<div className="fixed inset-0 z-40 lg:hidden" style={{ backgroundColor: 'rgba(62,44,28,0.4)' }} onClick={() => setMenuOpen(false)} aria-hidden="true" />
<aside className="fixed inset-y-0 left-0 w-80 z-50 flex flex-col lg:hidden" style={{ backgroundColor: 'var(--surface)' }}>
<div className="flex items-center justify-between px-5 h-16 border-b" style={{ borderColor: 'var(--border)' }}>
<img src={LOGO_HOMENEST} alt="SKEEN" className="h-8 w-auto object-contain" />
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label="Cerrar menú">
<X size={22} />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{topNavItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
to={item.href}
onClick={() => setMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition"
style={{
backgroundColor: active ? 'var(--accent)' : 'var(--bg)',
color: active ? 'var(--text-inverse)' : 'var(--text-heading)',
}}
>
<Icon size={18} /> {item.label}
</Link>
);
})}
{visibleSections.flatMap((s) => s.items).filter((it) => !topNavItems.some((t) => t.href === it.href)).map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
to={item.href}
onClick={() => setMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition"
style={{
backgroundColor: active ? 'var(--accent)' : 'var(--bg)',
color: active ? 'var(--text-inverse)' : 'var(--text-heading)',
}}
>
<Icon size={18} /> {item.label}
</Link>
);
})}
</div>
<div className="p-4 border-t" style={{ borderColor: 'var(--border)' }}>
<button onClick={handleLogout} className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium w-full transition" style={{ color: 'var(--text-heading)' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<LogOut size={18} /> Cerrar sesión
</button>
</div>
</aside>
</>
)}
<Modal
isOpen={goalModalOpen}
onClose={() => setGoalModalOpen(false)}
title="Meta de ventas del mes"
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Define el objetivo de ventas (MXN) para el mes en curso.</p>
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
</div>
</Modal>
</div>
);
};
export default HomeNestLayout;

View File

@@ -0,0 +1,20 @@
import type { FC, ReactNode } from 'react';
import SkeenLayout from './SkeenLayout';
import HomeNestLayout from './HomeNestLayout';
import { useTheme } from '../lib/theme';
interface LayoutProps {
children: ReactNode;
title: string;
subtitle?: string;
}
const Layout: FC<LayoutProps> = (props) => {
const { theme } = useTheme();
if (theme === 'homenest') {
return <HomeNestLayout {...props} />;
}
return <SkeenLayout {...props} />;
};
export default Layout;

View File

@@ -0,0 +1,373 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Calendar,
Users,
Stethoscope,
Briefcase,
Package,
Wallet,
CreditCard,
ShoppingCart,
Receipt,
BarChart3,
Settings,
Bell,
Search,
Menu,
X,
ChevronRight,
LogOut,
MessageSquare,
Target,
Cake,
Boxes,
ShieldCheck,
Palette,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useTheme } from '../lib/theme';
import { odooApi } from '../services/odoo';
interface LayoutProps {
children: ReactNode;
title: string;
subtitle?: string;
}
const LOGO_SKEEN = '/skeen-brand/logos/Logo%20Completo%20Blanco.png';
const menuGroups = [
{
label: 'Principal',
items: [
{ label: 'Dashboard', icon: LayoutDashboard, href: '/' },
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
],
},
{
label: 'Clínica',
items: [
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
{ label: 'Productos', icon: Package, href: '/productos' },
],
},
{
label: 'Operaciones',
items: [
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
{ label: 'Pagos', icon: CreditCard, href: '/pagos' },
{ label: 'Monedero', icon: Wallet, href: '/monedero' },
{ label: 'Inventario', icon: Boxes, href: '/inventario' },
{ label: 'Cortes de Caja', icon: Receipt, href: '/cortes' },
],
},
{
label: 'Analítica',
items: [
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
],
},
{
label: 'Comunicación',
items: [
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
],
},
{
label: 'Administración',
items: [
{ label: 'Usuarios', icon: ShieldCheck, href: '/usuarios' },
],
},
{
label: 'WACRM',
items: [
{ label: 'Mensajes', icon: MessageSquare, href: '/wacrm/messages' },
{ label: 'Leads', icon: Target, href: '/wacrm/leads' },
],
},
];
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout } = useAuth();
const { toggleTheme } = useTheme();
const [sidebarOpen, setSidebarOpen] = useState(false);
const visibleGroups = useMemo(
() =>
menuGroups
.map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.href)) }))
.filter((g) => g.items.length > 0),
[canSee, user]
);
const initials = (user?.name || 'US')
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join('');
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const [goal, setGoal] = useState<{ goal: number; current: number; pct: number } | null>(null);
const [goalModalOpen, setGoalModalOpen] = useState(false);
const [goalInput, setGoalInput] = useState('');
const [goalSaving, setGoalSaving] = useState(false);
useEffect(() => {
let alive = true;
const fetchGoal = () => {
odooApi.getSalesGoal()
.then((res) => {
if (alive && res.status === 'success') {
setGoal({ goal: res.goal, current: res.current, pct: res.pct });
}
})
.catch(() => {});
};
fetchGoal();
const id = setInterval(fetchGoal, 60000);
return () => { alive = false; clearInterval(id); };
}, []);
const openGoalModal = () => {
setGoalInput(goal?.goal ? String(goal.goal) : '');
setGoalModalOpen(true);
};
const saveGoal = async () => {
const v = parseFloat(goalInput);
if (Number.isNaN(v) || v < 0) return;
try {
setGoalSaving(true);
await odooApi.setSalesGoal(v);
setGoal((g) => (g ? { ...g, goal: v, pct: g.current && v ? (g.current / v) * 100 : 0 } : g));
setGoalModalOpen(false);
} catch {
// ignore
} finally {
setGoalSaving(false);
}
};
const fmtMoney = (n: number) =>
n >= 1000000 ? `$${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `$${(n / 1000).toFixed(0)}k` : `$${n.toFixed(0)}`;
const pctClamped = Math.min(goal?.pct ?? 0, 100);
useEffect(() => {
if (!sidebarOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [sidebarOpen]);
const isActive = (href: string) => {
if (href === '/') return location.pathname === '/';
return location.pathname === href || location.pathname.startsWith(`${href}/`);
};
const SidebarContent = () => (
<>
<div className="h-20 flex items-center px-5 lg:px-6 border-b border-white/10 shrink-0">
<img src={LOGO_SKEEN} alt="SKEEN" className="h-7 w-auto object-contain" />
</div>
<nav className="flex-1 overflow-y-auto py-5 px-3">
{visibleGroups.map((group) => (
<div key={group.label} className="mb-6">
<p className="px-3 text-[10px] uppercase tracking-wider text-white/40 mb-2 font-heading">{group.label}</p>
<ul className="space-y-1">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.label}>
<Link
to={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
active
? 'bg-theme-accent text-theme-inverse shadow-soft'
: 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
}`}
>
<Icon size={18} />
<span>{item.label}</span>
{active && <ChevronRight size={14} className="ml-auto" />}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="p-4 border-t border-white/10 shrink-0">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-full bg-theme-accent/20 flex items-center justify-center text-sm font-bold text-theme-accent">
{initials || 'US'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
<p className="text-xs text-white/50 truncate">{user ? ROLE_LABELS[user.role] : ''}</p>
</div>
<button
onClick={handleLogout}
className="p-2 rounded-lg text-white/50 hover:text-white hover:bg-theme-surface/10"
title="Cerrar sesión"
aria-label="Cerrar sesión"
>
<LogOut size={18} />
</button>
</div>
</div>
</>
);
return (
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
<aside className="hidden lg:flex flex-col w-64 fixed h-full z-20" style={{ backgroundColor: 'var(--sidebar-bg)' }}>
<SidebarContent />
</aside>
{sidebarOpen && (
<div className="fixed inset-0 bg-black/40 z-30 lg:hidden" onClick={() => setSidebarOpen(false)} aria-hidden="true" />
)}
<aside
className={`fixed inset-y-0 left-0 w-64 z-40 transform transition-transform duration-200 ease-in-out lg:hidden flex flex-col ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
style={{ backgroundColor: 'var(--sidebar-bg)' }}
>
<div className="h-20 flex items-center justify-between px-5 border-b border-white/10 shrink-0">
<img src={LOGO_SKEEN} alt="SKEEN" className="h-7 w-auto object-contain" />
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(false)} aria-label="Cerrar menú">
<X size={22} />
</Button>
</div>
<SidebarContent />
</aside>
<div className="flex-1 lg:ml-64 min-h-screen flex flex-col w-full">
<header className="backdrop-blur border-b sticky top-0 z-10" style={{ backgroundColor: 'var(--header-bg)', borderColor: 'var(--border)' }}>
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center space-x-3 min-w-0">
<Button
variant="ghost"
size="sm"
className="lg:hidden"
style={{ color: 'var(--text-heading)' }}
onClick={() => setSidebarOpen(true)}
aria-label="Abrir menú"
>
<Menu size={22} />
</Button>
<div className="min-w-0">
<h2 className="text-lg font-bold truncate font-heading tracking-tight" style={{ color: 'var(--text-heading)' }}>{title}</h2>
{subtitle && <p className="text-xs hidden sm:block truncate" style={{ color: 'var(--text-muted)' }}>{subtitle}</p>}
</div>
</div>
<div className="flex items-center space-x-2 sm:space-x-3 shrink-0">
{goal && goal.goal > 0 && (
<button
onClick={hasRole('admin') ? openGoalModal : undefined}
className={`hidden md:flex flex-col w-44 xl:w-56 text-left rounded-xl border px-3 py-1.5 shadow-card ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
title={hasRole('admin') ? 'Click para editar la meta' : 'Meta de ventas del mes'}
>
<div className="flex items-center justify-between text-[11px] mb-1" style={{ color: 'var(--text-muted)' }}>
<span className="flex items-center gap-1"><Target size={12} /> Meta del mes</span>
<span className="font-bold" style={{ color: 'var(--text-heading)' }}>{Math.round(goal.pct)}%</span>
</div>
<div className="w-full h-1.5 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--bg)' }}>
<div className="h-full rounded-full transition-all" style={{ width: `${pctClamped}%`, backgroundColor: goal.pct >= 100 ? 'var(--success)' : 'var(--accent)' }} />
</div>
<div className="flex items-center justify-between text-[11px] mt-1">
<span className="font-semibold" style={{ color: 'var(--text-heading)' }}>{fmtMoney(goal.current)}</span>
<span style={{ color: 'var(--text-muted)' }}>de {fmtMoney(goal.goal)}</span>
</div>
</button>
)}
{goal && goal.goal <= 0 && hasRole('admin') && (
<Button variant="outline" size="sm" className="hidden md:inline-flex" onClick={openGoalModal}>
<Target size={14} className="mr-1.5" /> Definir meta
</Button>
)}
<div className="hidden md:flex items-center rounded-full px-3 py-1.5 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<Search size={14} style={{ color: 'var(--text-muted)' }} />
<input
type="text"
placeholder="Buscar paciente, cita..."
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-48 placeholder:text-theme-muted/60"
style={{ color: 'var(--text-heading)' }}
/>
</div>
<button className="p-2 rounded-full relative transition" style={{ color: 'var(--text-muted)' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Bell size={18} />
<span className="absolute top-1 right-1 w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent)' }} />
</button>
<button
onClick={toggleTheme}
title="Cambiar a HomeNest"
className="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition border"
style={{ backgroundColor: 'var(--surface)', color: 'var(--text-heading)', borderColor: 'var(--border)' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--surface)'}
>
<Palette size={14} />
HomeNest
</button>
</div>
</div>
</div>
</header>
<main className="flex-1 w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{children}
</main>
</div>
<Modal
isOpen={goalModalOpen}
onClose={() => setGoalModalOpen(false)}
title="Meta de ventas del mes"
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.</p>
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
</div>
</Modal>
</div>
);
};
export default SkeenLayout;

View File

@@ -0,0 +1,62 @@
import type { FC, ReactNode } from 'react';
import { cn } from '../../lib/utils';
export type BadgeVariant =
| 'confirmed'
| 'pending'
| 'done'
| 'cancelled'
| 'paid'
| 'unpaid'
| 'info'
| 'default'
| 'warning'
| 'danger'
| 'success'
| 'primary';
interface BadgeProps {
children: ReactNode;
variant?: BadgeVariant;
className?: string;
}
const variants: Record<BadgeVariant, string> = {
confirmed: 'bg-theme-success text-theme-success-text',
pending: 'bg-theme-accent-bg text-theme-heading',
done: 'bg-theme-lilac text-theme-heading',
cancelled: 'bg-rose-100 text-rose-700',
paid: 'bg-theme-success text-theme-success-text',
unpaid: 'bg-rose-100 text-rose-700',
info: 'bg-theme-sky text-theme-heading',
default: 'bg-theme-accent-bg text-theme-muted',
primary: 'bg-theme-accent text-theme-inverse',
warning: 'bg-theme-warning text-theme-warning-text',
danger: 'bg-rose-100 text-rose-700',
success: 'bg-theme-success text-theme-success-text',
};
const normalizeVariant = (variant: BadgeVariant): BadgeVariant => {
if (variant === 'confirmed' || variant === 'paid' || variant === 'success') return variant;
if (variant === 'pending' || variant === 'warning') return variant;
if (variant === 'done' || variant === 'info') return variant;
if (variant === 'cancelled' || variant === 'danger' || variant === 'unpaid') return variant;
return 'default';
};
export const Badge: FC<BadgeProps> = ({ children, variant = 'default', className }) => {
const normalized = normalizeVariant(variant);
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
variants[normalized],
className
)}
>
{children}
</span>
);
};
export default Badge;

View File

@@ -0,0 +1,60 @@
import type { ButtonHTMLAttributes, FC, ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '../../lib/utils';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
fullWidth?: boolean;
children: ReactNode;
}
const variants: Record<NonNullable<ButtonProps['variant']>, string> = {
primary:
'bg-theme-accent text-theme-inverse hover:bg-theme-accent-hover focus:ring-theme-accent disabled:bg-theme-accent/40 shadow-soft',
secondary:
'bg-theme-success text-theme-success-text hover:opacity-90 focus:ring-theme-success disabled:opacity-40',
ghost:
'bg-transparent text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading focus:ring-theme-accent',
danger:
'bg-rose-500 text-white hover:bg-rose-600 focus:ring-rose-500 disabled:bg-rose-300',
outline:
'bg-theme-surface text-theme-heading border border-theme-border-strong hover:border-theme-accent hover:text-theme-accent focus:ring-theme-accent',
};
const sizes: Record<NonNullable<ButtonProps['size']>, string> = {
sm: 'px-3 py-1.5 text-xs rounded-lg',
md: 'px-4 py-2 text-sm rounded-xl',
lg: 'px-6 py-3 text-base rounded-xl',
};
export const Button: FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
children,
className,
disabled,
...props
}) => {
return (
<button
className={cn(
'inline-flex items-center justify-center font-medium transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed rounded-xl',
variants[variant],
sizes[size],
fullWidth && 'w-full',
className
)}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 size={size === 'lg' ? 18 : 14} className="mr-2 animate-spin" />}
{children}
</button>
);
};
export default Button;

View File

@@ -0,0 +1,61 @@
import type { FC, ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface CardProps {
children: ReactNode;
className?: string;
noPadding?: boolean;
}
interface CardHeaderProps {
children: ReactNode;
className?: string;
}
interface CardBodyProps {
children: ReactNode;
className?: string;
noPadding?: boolean;
}
export const Card: FC<CardProps> & {
Header: FC<CardHeaderProps>;
Body: FC<CardBodyProps>;
} = ({ children, className }) => {
return (
<div
className={cn(
'bg-theme-surface rounded-2xl border border-theme-border shadow-card',
className
)}
>
{children}
</div>
);
};
const CardHeader: FC<CardHeaderProps> = ({ children, className }) => {
return (
<div
className={cn(
'px-4 py-4 sm:px-6 sm:py-5 border-b border-theme-border flex flex-col sm:flex-row sm:items-center justify-between gap-3',
className
)}
>
{children}
</div>
);
};
const CardBody: FC<CardBodyProps> = ({ children, className, noPadding = false }) => {
return (
<div className={cn(!noPadding && 'p-4 sm:p-6', className)}>
{children}
</div>
);
};
Card.Header = CardHeader;
Card.Body = CardBody;
export default Card;

View File

@@ -0,0 +1,39 @@
import type { FC, ReactNode } from 'react';
import { Inbox } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from './Button';
interface EmptyStateProps {
title?: string;
subtitle?: string;
icon?: ReactNode;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
export const EmptyState: FC<EmptyStateProps> = ({
title = 'Sin resultados',
subtitle = 'No hay datos para mostrar en este momento.',
icon,
actionLabel,
onAction,
className,
}) => {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
<div className="w-14 h-14 rounded-full bg-theme-bg flex items-center justify-center text-theme-muted mb-4">
{icon ?? <Inbox size={28} />}
</div>
<h4 className="font-heading text-lg text-theme-heading mb-1">{title}</h4>
<p className="text-sm text-theme-muted max-w-xs mx-auto mb-4">{subtitle}</p>
{actionLabel && onAction && (
<Button variant="outline" size="sm" onClick={onAction}>
{actionLabel}
</Button>
)}
</div>
);
};
export default EmptyState;

View File

@@ -0,0 +1,34 @@
import type { FC, InputHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
inputClassName?: string;
labelClassName?: string;
}
export const Input: FC<InputProps> = ({ label, error, className, inputClassName, labelClassName, id, ...props }) => {
const inputId = id ?? (label ? `input-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
return (
<div className={cn('w-full', className)}>
{label && (
<label htmlFor={inputId} className={cn('block text-xs font-medium mb-1.5', labelClassName || 'text-theme-muted')}>
{label}
</label>
)}
<input
id={inputId}
className={cn(
'w-full rounded-xl border bg-theme-surface px-3 py-2.5 text-sm text-theme-heading placeholder:text-theme-muted/60 focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition',
error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-theme-border-strong',
inputClassName
)}
{...props}
/>
{error && <p className="mt-1.5 text-xs text-rose-600">{error}</p>}
</div>
);
};
export default Input;

View File

@@ -0,0 +1,50 @@
import type { FC, ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface MobileCardProps {
title: ReactNode;
subtitle?: ReactNode;
rows: { label: string; value: ReactNode }[];
actions?: ReactNode;
onClick?: () => void;
className?: string;
}
export const MobileCard: FC<MobileCardProps> = ({
title,
subtitle,
rows,
actions,
onClick,
className,
}) => {
return (
<div
className={cn(
'bg-theme-surface rounded-2xl border border-theme-border shadow-sm p-4 sm:hidden',
onClick && 'cursor-pointer active:bg-theme-bg',
className
)}
onClick={onClick}
role={onClick ? 'button' : undefined}
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<div className="text-sm font-medium text-theme-heading truncate">{title}</div>
{subtitle && <div className="text-xs text-theme-muted mt-0.5">{subtitle}</div>}
</div>
{actions && <div className="flex items-center gap-1 shrink-0">{actions}</div>}
</div>
<div className="grid grid-cols-2 gap-y-2 gap-x-3">
{rows.map((row, i) => (
<div key={i} className={cn(i === 0 && rows.length % 2 === 1 ? 'col-span-2' : '')}>
<p className="text-[10px] uppercase tracking-wider text-theme-muted">{row.label}</p>
<div className="text-sm text-theme-heading truncate">{row.value}</div>
</div>
))}
</div>
</div>
);
};
export default MobileCard;

View File

@@ -0,0 +1,86 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from './Button';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: ReactNode;
children: ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
footer?: ReactNode;
}
const maxWidthClasses: Record<NonNullable<ModalProps['maxWidth']>, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
full: 'max-w-[calc(100vw-2rem)]',
};
export const Modal: FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
maxWidth = 'md',
footer,
}) => {
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
return () => {
document.body.style.overflow = originalOverflow;
document.removeEventListener('keydown', handleKey);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
aria-modal="true"
role="dialog"
>
<div
className="absolute inset-0 bg-brand-dark/40"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={contentRef}
className={cn(
'relative bg-theme-surface rounded-2xl shadow-xl w-full flex flex-col max-h-[calc(100vh-2rem)]',
maxWidthClasses[maxWidth]
)}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-theme-border">
<h3 className="font-heading text-lg sm:text-xl text-theme-heading pr-4">{title}</h3>
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Cerrar">
<X size={18} />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-5 sm:p-6">{children}</div>
{footer && (
<div className="px-5 py-4 border-t border-theme-border flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
{footer}
</div>
)}
</div>
</div>
);
};
export default Modal;

View File

@@ -0,0 +1,28 @@
import type { FC, ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface PageHeaderProps {
title: string;
subtitle?: string;
children?: ReactNode;
className?: string;
}
export const PageHeader: FC<PageHeaderProps> = ({ title, subtitle, children, className }) => {
return (
<div
className={cn(
'flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6',
className
)}
>
<div>
<h2 className="font-heading text-2xl sm:text-3xl text-theme-heading">{title}</h2>
{subtitle && <p className="text-sm text-theme-muted mt-1">{subtitle}</p>}
</div>
{children && <div className="flex items-center gap-2 shrink-0">{children}</div>}
</div>
);
};
export default PageHeader;

View File

@@ -0,0 +1,38 @@
import type { FC, SelectHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: { value: string; label: string }[];
}
export const Select: FC<SelectProps> = ({ label, error, className, options, id, ...props }) => {
const selectId = id ?? (label ? `select-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
return (
<div className={cn('w-full', className)}>
{label && (
<label htmlFor={selectId} className="block text-xs font-medium text-theme-muted mb-1.5">
{label}
</label>
)}
<select
id={selectId}
className={cn(
'w-full rounded-xl border bg-theme-surface px-3 py-2.5 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition appearance-none',
error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-theme-border-strong'
)}
{...props}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <p className="mt-1.5 text-xs text-rose-600">{error}</p>}
</div>
);
};
export default Select;

View File

@@ -0,0 +1,52 @@
import type { FC } from 'react';
import { cn } from '../../lib/utils';
interface SkeletonProps {
className?: string;
count?: number;
}
export const Skeleton: FC<SkeletonProps> = ({ className, count = 1 }) => {
return (
<div className="space-y-2 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className={cn('rounded-xl bg-theme-border-strong', className)}
/>
))}
</div>
);
};
export const SkeletonCard: FC<{ lines?: number; className?: string }> = ({
lines = 3,
className,
}) => {
return (
<div className={cn('bg-theme-surface rounded-2xl p-4 sm:p-6 border border-theme-border shadow-sm', className)}>
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-4 w-16" />
</div>
<div className="space-y-3">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
);
};
export const SkeletonStat: FC = () => (
<div className="bg-theme-surface rounded-2xl p-5 border border-theme-border shadow-sm">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="h-3 w-20 mb-2" />
<Skeleton className="h-7 w-24" />
</div>
);
export default Skeleton;

View File

@@ -0,0 +1,31 @@
import type { FC, TextareaHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
export const TextArea: FC<TextAreaProps> = ({ label, error, className, id, ...props }) => {
const areaId = id ?? (label ? `textarea-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
return (
<div className={cn('w-full', className)}>
{label && (
<label htmlFor={areaId} className="block text-xs font-medium text-theme-muted mb-1.5">
{label}
</label>
)}
<textarea
id={areaId}
className={cn(
'w-full rounded-xl border bg-theme-surface px-3 py-2.5 text-sm text-theme-heading placeholder:text-theme-muted/60 focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition resize-y min-h-[80px]',
error ? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20' : 'border-theme-border-strong'
)}
{...props}
/>
{error && <p className="mt-1.5 text-xs text-rose-600">{error}</p>}
</div>
);
};
export default TextArea;

View File

@@ -0,0 +1,19 @@
import { Toaster } from 'sonner';
export const ToastProvider = () => {
return (
<Toaster
position="top-right"
richColors
closeButton
toastOptions={{
style: {
fontFamily:
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
}}
/>
);
};
export default ToastProvider;

View File

@@ -0,0 +1,19 @@
export { Button } from './Button';
export { Card } from './Card';
export { Input } from './Input';
export { Select } from './Select';
export { TextArea } from './TextArea';
export { Modal } from './Modal';
export { Badge } from './Badge';
export { EmptyState } from './EmptyState';
export { Skeleton, SkeletonCard, SkeletonStat } from './Skeleton';
export { MobileCard } from './MobileCard';
export { PageHeader } from './PageHeader';
export { ToastProvider } from './Toast';
export { toast } from '../../lib/toast';
export {
badgeForAppointmentState,
badgeForPaymentState,
badgeForSaleState,
badgeForCashClosingState,
} from '../../lib/badges';

178
frontend/src/index.css Normal file
View File

@@ -0,0 +1,178 @@
@import "tailwindcss";
@import "./styles/fonts.css";
/* =========================================================
Tema SKEEN Derma Experts (default)
========================================================= */
:root,
[data-theme="skeen"] {
/* Paleta */
--color-coral: #EDA588;
--color-coral-hover: #d98b6c;
--color-mint: #CAE2C1;
--color-lilac: #C7BCDD;
--color-sky: #A4CCE2;
--color-dark: #1A1A1A;
--color-charcoal: #2D2D2D;
--color-gray: #6B6B6B;
--color-light: #F8F6F4;
--color-white: #FFFFFF;
/* Semántico */
--text: var(--color-charcoal);
--text-muted: var(--color-gray);
--text-heading: var(--color-dark);
--text-inverse: var(--color-white);
--bg: var(--color-light);
--surface: var(--color-white);
--surface-elevated: var(--color-white);
--border: rgba(26, 26, 26, 0.08);
--border-strong: #E5E0DC;
--accent: var(--color-coral);
--accent-hover: var(--color-coral-hover);
--accent-bg: rgba(237, 165, 136, 0.12);
--success: var(--color-mint);
--success-text: var(--color-dark);
--warning: #FEF3C7;
--warning-text: #92400E;
--font-sans: 'Sans Essential', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-display: 'Carefree Serif', Georgia, serif;
--font-heading: 'TT Hoves Pro', 'Sans Essential', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--sidebar-bg: var(--color-dark);
--sidebar-text: rgba(255, 255, 255, 0.7);
--sidebar-text-active: var(--color-white);
--sidebar-active-bg: var(--color-coral);
--header-bg: rgba(255, 255, 255, 0.8);
--logo: '/skeen-brand/logos/Logo%20Completo%20Blanco.png';
}
/* =========================================================
Tema HomeNest
========================================================= */
[data-theme="homenest"] {
/* Paleta */
--color-coral: #8B5E3C;
--color-coral-hover: #6F4B30;
--color-mint: #D9F99D;
--color-lilac: #F3E2B3;
--color-sky: #E9D5B7;
--color-dark: #3E2C1C;
--color-charcoal: #5C4634;
--color-gray: #7A5C44;
--color-light: #FEF3C7;
--color-white: #FFF9E6;
/* Semántico */
--text: var(--color-dark);
--text-muted: var(--color-gray);
--text-heading: var(--color-dark);
--text-inverse: var(--color-light);
--bg: var(--color-light);
--surface: var(--color-white);
--surface-elevated: var(--color-white);
--border: var(--color-sky);
--border-strong: var(--color-sky);
--accent: var(--color-coral);
--accent-hover: var(--color-coral-hover);
--accent-bg: var(--color-lilac);
--success: var(--color-mint);
--success-text: var(--color-dark);
--warning: #FEF3C7;
--warning-text: var(--color-dark);
--font-sans: 'DM Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-display: 'Alike', Georgia, serif;
--font-heading: 'Alike', Georgia, serif;
--font-mono: 'JetBrains Mono', monospace;
--sidebar-bg: var(--color-white);
--sidebar-text: var(--color-gray);
--sidebar-text-active: var(--color-dark);
--sidebar-active-bg: var(--color-coral);
--header-bg: rgba(255, 249, 230, 0.8);
--logo: '/skeen-brand/logos/Logo%20Completo%20Negro.png';
}
/* =========================================================
Base
========================================================= */
html {
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 100%;
max-width: 100%;
margin: 0;
text-align: left;
border: none;
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
color: var(--text);
background: var(--bg);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 700;
color: var(--text-heading);
letter-spacing: -0.02em;
margin: 0;
}
[data-theme="homenest"] h1,
[data-theme="homenest"] h2,
[data-theme="homenest"] h3,
[data-theme="homenest"] h4,
[data-theme="homenest"] h5,
[data-theme="homenest"] h6 {
font-weight: 400;
letter-spacing: -0.01em;
}
.font-display {
font-family: var(--font-display);
}
.font-mono {
font-family: var(--font-mono);
}
p {
margin: 0;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-coral);
opacity: 0.25;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
opacity: 0.4;
}

135
frontend/src/lib/auth.tsx Normal file
View File

@@ -0,0 +1,135 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
const TOKEN_KEY = 'skeen_token';
const USER_KEY = 'skeen_user';
const ROLE_RANK: Record<FrontendRole, number> = {
lectura: 10,
medico: 50,
recepcion: 50,
admin: 100,
};
// Visibilidad del menú por ruta. Si una ruta no está listada, se muestra a todos los roles.
const MENU_ROLES: Record<string, FrontendRole[]> = {
'/': ['admin', 'recepcion', 'medico', 'lectura'],
'/agenda': ['admin', 'recepcion', 'medico'],
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
'/medicos': ['admin', 'recepcion'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura'],
'/productos': ['admin', 'recepcion', 'lectura'],
'/ventas': ['admin', 'recepcion'],
'/pagos': ['admin', 'recepcion'],
'/monedero': ['admin', 'recepcion', 'lectura'],
'/inventario': ['admin', 'recepcion', 'lectura'],
'/cortes': ['admin', 'recepcion'],
'/reportes': ['admin', 'recepcion', 'lectura'],
'/configuracion': ['admin'],
'/usuarios': ['admin'],
'/cumpleanos': ['admin', 'recepcion'],
'/wacrm/messages': ['admin', 'recepcion'],
'/wacrm/leads': ['admin', 'recepcion'],
};
interface AuthContextValue {
user: FrontendUser | null;
token: string | null;
loading: boolean;
login: (login: string, password: string) => Promise<FrontendUser>;
logout: () => Promise<void>;
hasRole: (min: FrontendRole) => boolean;
canSee: (href: string) => boolean;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<FrontendUser | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [loading, setLoading] = useState(true);
const clear = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setToken(null);
setUser(null);
}, []);
// Validar token al arrancar
useEffect(() => {
let alive = true;
const t = localStorage.getItem(TOKEN_KEY);
if (!t) {
setLoading(false);
return;
}
// Hidratar usuario cacheado para evitar parpadeo
const cached = localStorage.getItem(USER_KEY);
if (cached) {
try { setUser(JSON.parse(cached)); } catch { /* ignore */ }
}
odooApi.me()
.then((res) => {
if (!alive) return;
if (res.status === 'success') {
setUser(res.user);
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
} else {
clear();
}
})
.catch(() => { if (alive) clear(); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [clear]);
const login = async (login: string, password: string): Promise<FrontendUser> => {
const res = await odooApi.login(login, password);
if (res.status !== 'success' || !res.token) {
throw new Error(res.message || 'Credenciales inválidas');
}
localStorage.setItem(TOKEN_KEY, res.token);
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
setToken(res.token);
setUser(res.user);
return res.user;
};
const logout = async () => {
try { await odooApi.logout(); } catch { /* ignore */ }
clear();
};
const hasRole = (min: FrontendRole): boolean => {
if (!user) return false;
return (ROLE_RANK[user.role] ?? 0) >= (ROLE_RANK[min] ?? 0);
};
const canSee = (href: string): boolean => {
if (!user) return false;
const allowed = MENU_ROLES[href];
if (!allowed) return true;
return allowed.includes(user.role);
};
return (
<AuthContext.Provider value={{ user, token, loading, login, logout, hasRole, canSee }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextValue => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider');
return ctx;
};
export const ROLE_LABELS: Record<FrontendRole, string> = {
admin: 'Administrador',
recepcion: 'Recepción',
medico: 'Médico',
lectura: 'Solo lectura',
};

View File

@@ -0,0 +1,50 @@
import type { BadgeVariant } from '../components/ui/Badge';
export const badgeForAppointmentState = (state: string): BadgeVariant => {
switch (state) {
case 'confirmed':
return 'confirmed';
case 'done':
case 'completed':
return 'done';
case 'cancelled':
return 'cancelled';
default:
return 'pending';
}
};
export const badgeForPaymentState = (state: string): BadgeVariant => {
switch (state) {
case 'confirmed':
case 'posted':
case 'paid':
return 'paid';
default:
return 'unpaid';
}
};
export const badgeForSaleState = (state: string): BadgeVariant => {
switch (state) {
case 'sale':
case 'done':
case 'paid':
return 'paid';
case 'cancel':
return 'cancelled';
default:
return 'pending';
}
};
export const badgeForCashClosingState = (state: string): BadgeVariant => {
switch (state) {
case 'closed':
return 'done';
case 'open':
return 'pending';
default:
return 'default';
}
};

View File

@@ -0,0 +1,50 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
type Theme = 'skeen' | 'homenest';
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
const STORAGE_KEY = 'skeen-theme';
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'skeen';
const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
if (stored === 'skeen' || stored === 'homenest') return stored;
return 'skeen';
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
const setTheme = (next: Theme) => {
setThemeState(next);
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, next);
document.documentElement.setAttribute('data-theme', next);
}
};
const toggleTheme = () => setTheme(theme === 'skeen' ? 'homenest' : 'skeen');
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}

View File

@@ -0,0 +1 @@
export { toast } from 'sonner';

27
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,27 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
type CsvCell = string | number | boolean | null | undefined;
/**
* Descarga un CSV (UTF-8 con BOM para que Excel respete acentos).
* Cada fila es un arreglo de celdas; se escapan comillas automáticamente.
*/
export function downloadCsv(filename: string, rows: CsvCell[][]) {
const csv = rows
.map((row) => row.map((cell) => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
.join('\r\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './lib/theme.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,642 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, Plus, PackageCheck, Sparkles, RotateCcw } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
Select,
TextArea,
Modal,
Badge,
EmptyState,
MobileCard,
PageHeader,
Skeleton,
toast,
badgeForAppointmentState,
} from '../components/ui';
import { odooApi, type Appointment, type Patient, type Service, type Doctor } from '../services/odoo';
const stateOptions = [
{ value: '', label: 'Todos los estados' },
{ value: 'pending', label: 'Pendiente' },
{ value: 'confirmed', label: 'Confirmada' },
{ value: 'done', label: 'Completada' },
{ value: 'cancelled', label: 'Cancelada' },
];
const Agenda: FC = () => {
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [updating, setUpdating] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [date, setDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
const [slotsLoading, setSlotsLoading] = useState(false);
const [form, setForm] = useState({
patient_id: '',
service_id: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
time: '',
notes: '',
sessions: 1,
});
const [patientSearch, setPatientSearch] = useState('');
const [pkgUpdating, setPkgUpdating] = useState<number | null>(null);
const [expressOpen, setExpressOpen] = useState(false);
const [expressSubmitting, setExpressSubmitting] = useState(false);
const [expressForm, setExpressForm] = useState({
name: '',
phone: '',
notes: '',
withAppointment: false,
service_id: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
time: '',
});
const filteredAppointments = appointments.filter((apt) => {
if (stateFilter && apt.state !== stateFilter) return false;
if (doctorFilter && String(apt.doctor_id) !== doctorFilter) return false;
return true;
});
const load = useCallback(async () => {
try {
setLoading(true);
setError(null);
const params: Record<string, string> = date ? { date } : {};
const res = await odooApi.getAppointments(params);
if (res.status === 'success') setAppointments(res.appointments);
} catch (err) {
setError('Error al cargar citas');
toast.error('Error al cargar citas');
console.error(err);
} finally {
setLoading(false);
}
}, [date]);
const loadReferences = useCallback(async () => {
try {
const [servicesRes, doctorsRes] = await Promise.all([
odooApi.getServices(),
odooApi.getDoctors(),
]);
if (servicesRes.status === 'success') setServices(servicesRes.services);
if (doctorsRes.status === 'success') setDoctors(doctorsRes.doctors);
} catch (err) {
console.error(err);
}
}, []);
useEffect(() => {
load();
loadReferences();
}, [load, loadReferences]);
// Cargar pacientes para el selector (búsqueda server-side)
useEffect(() => {
if (!createOpen) return;
const t = setTimeout(async () => {
try {
const res = await odooApi.getPatients({ search: patientSearch, page_size: 50 });
if (res.status === 'success') setPatients(res.patients);
} catch (err) {
console.error(err);
}
}, 250);
return () => clearTimeout(t);
}, [createOpen, patientSearch]);
const fetchSlots = useCallback(async (serviceId: string, aptDate: string) => {
if (!serviceId || !aptDate) {
setSlots([]);
return;
}
try {
setSlotsLoading(true);
const res = await odooApi.getAvailableSlots(aptDate, parseInt(serviceId, 10));
if (res.status === 'success') setSlots(res.slots);
} catch {
toast.error('Error al cargar horarios disponibles');
setSlots([]);
} finally {
setSlotsLoading(false);
}
}, []);
useEffect(() => {
fetchSlots(form.service_id, form.date);
}, [form.service_id, form.date, fetchSlots]);
const handleStatus = async (id: number, action: string) => {
try {
setUpdating(id);
await odooApi.updateAppointmentStatus(id, action);
toast.success('Estado actualizado');
await load();
} catch (err) {
toast.error('Error al actualizar estado');
console.error(err);
} finally {
setUpdating(null);
}
};
const handlePackageFinished = async (id: number, finished: boolean) => {
try {
setPkgUpdating(id);
await odooApi.packageFinished(id, finished);
toast.success(finished ? 'Paquete marcado como terminado' : 'Marca de paquete revertida');
await load();
} catch (err) {
toast.error('Error al actualizar paquete');
console.error(err);
} finally {
setPkgUpdating(null);
}
};
const parseTimeToFloat = (t: string): number | undefined => {
if (!t) return undefined;
const [h, m] = t.split(':').map((x) => parseInt(x, 10));
if (Number.isNaN(h)) return undefined;
return h + (Number.isNaN(m) ? 0 : m / 60);
};
const resetExpressForm = () =>
setExpressForm({
name: '', phone: '', notes: '', withAppointment: false,
service_id: '', doctor_id: '', date: new Date().toISOString().split('T')[0], time: '',
});
const submitExpress = async () => {
if (!expressForm.name.trim() || !expressForm.phone.trim()) {
toast.error('Nombre y teléfono son obligatorios');
return;
}
if (expressForm.withAppointment && (!expressForm.service_id || !expressForm.date || !expressForm.time)) {
toast.error('Para la cita de valoración selecciona servicio, fecha y hora');
return;
}
try {
setExpressSubmitting(true);
const patientPayload: Partial<Patient> & { notes?: string } = {
name: expressForm.name.trim(),
phone: expressForm.phone.trim(),
source: 'walkin',
notes: expressForm.notes || undefined,
};
const res = await odooApi.createPatient(patientPayload);
const newId = res.patient?.id;
if (expressForm.withAppointment && newId) {
await odooApi.createAppointment({
patient_id: newId,
service_id: parseInt(expressForm.service_id, 10),
doctor_id: expressForm.doctor_id ? parseInt(expressForm.doctor_id, 10) : undefined,
date: expressForm.date,
time: parseTimeToFloat(expressForm.time) as unknown as string,
notes: 'Valoración express',
state: 'confirmed',
});
}
toast.success(expressForm.withAppointment ? 'Prospecto y cita de valoración creados' : 'Prospecto creado');
setExpressOpen(false);
resetExpressForm();
await load();
} catch (err) {
toast.error('Error en valoración express');
console.error(err);
} finally {
setExpressSubmitting(false);
}
};
const createAppointment = async () => {
if (!form.patient_id || !form.service_id || !form.date || !form.time) {
toast.error('Completa los campos obligatorios');
return;
}
const service = services.find((s) => String(s.id) === form.service_id);
const sessions = form.sessions > 1 ? form.sessions : ((service?.package_sessions ?? 0) > 1 ? (service?.package_sessions ?? 1) : 1);
try {
setSubmitting(true);
await odooApi.createAppointment({
patient_id: parseInt(form.patient_id, 10),
service_id: parseInt(form.service_id, 10),
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : undefined,
date: form.date,
time: form.time,
notes: form.notes,
sessions,
});
toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada');
setCreateOpen(false);
setForm({
patient_id: '',
service_id: '',
doctor_id: '',
date: new Date().toISOString().split('T')[0],
time: '',
notes: '',
sessions: 1,
});
await load();
} catch (err) {
toast.error('Error al crear cita');
console.error(err);
} finally {
setSubmitting(false);
}
};
const doctorOptions = [
{ value: '', label: 'Todos los médicos' },
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
];
const patientOptions = [
{ value: '', label: 'Seleccionar paciente...' },
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
];
const serviceOptions = [
{ value: '', label: 'Seleccionar servicio...' },
...services.map((s) => ({
value: String(s.id),
label: (s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0
? `${s.name}$${s.price} / ${s.package_sessions} sesiones $${s.package_price}`
: `${s.name}$${s.price}`,
})),
];
const slotOptions = slots.length
? [
{ value: '', label: 'Seleccionar hora...' },
...slots.map((s) => ({ value: s.time_str, label: s.time_str })),
]
: [
{ value: '', label: 'Selecciona servicio y fecha primero' },
];
const TableHeader = () => (
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Hora</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Servicio</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Doctor</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Pago</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
);
const ActionButtons = ({ apt }: { apt: Appointment }) => (
<div className="flex items-center gap-1">
{apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'confirm')}
disabled={updating === apt.id}
title="Confirmar"
>
<CheckCircle size={16} className="text-theme-heading" />
</Button>
)}
{apt.state === 'confirmed' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'arrive')}
disabled={updating === apt.id}
title="Llegada"
>
<UserCheck size={16} className="text-blue-600" />
</Button>
)}
{apt.state !== 'done' && apt.state !== 'cancelled' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'done')}
disabled={updating === apt.id}
title="Completar"
>
<CheckCheck size={16} className="text-blue-600" />
</Button>
)}
{apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
apt.package_finished ? (
<Button
variant="ghost"
size="sm"
onClick={() => handlePackageFinished(apt.id, false)}
disabled={pkgUpdating === apt.id}
title="Paquete terminado — click para revertir"
>
<RotateCcw size={16} className="text-amber-600" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handlePackageFinished(apt.id, true)}
disabled={pkgUpdating === apt.id}
title="Marcar paquete terminado"
>
<PackageCheck size={16} className="text-violet-600" />
</Button>
)
)}
{apt.state !== 'cancelled' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'cancel')}
disabled={updating === apt.id}
title="Cancelar"
>
<XCircle size={16} className="text-rose-600" />
</Button>
)}
</div>
);
return (
<Layout title="Agenda" subtitle="Gestión de citas">
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
<Button variant="outline" onClick={() => setExpressOpen(true)}>
<Sparkles size={16} className="mr-2" />
Valoración express
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus size={16} className="mr-2" />
Nueva cita
</Button>
</PageHeader>
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4 mb-4 sm:mb-6">
<Input
label="Fecha"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="sm:max-w-[180px]"
/>
<Select
label="Estado"
options={stateOptions}
value={stateFilter}
onChange={(e) => setStateFilter(e.target.value)}
className="sm:max-w-[180px]"
/>
<Select
label="Médico"
options={doctorOptions}
value={doctorFilter}
onChange={(e) => setDoctorFilter(e.target.value)}
className="sm:max-w-[220px]"
/>
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : filteredAppointments.length === 0 ? (
<EmptyState
title="Sin citas"
subtitle="No hay citas para los filtros seleccionados."
actionLabel="Nueva cita"
onAction={() => setCreateOpen(true)}
/>
) : (
<>
{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<TableHeader />
<tbody className="divide-y">
{filteredAppointments.map((apt) => (
<tr key={apt.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-heading">
<Clock size={14} className="inline mr-1 text-theme-muted" />
{apt.time}
</td>
<td className="p-3 text-sm font-medium text-theme-heading">{apt.patient}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.service}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
<td className="p-3">
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.payment_state}</td>
<td className="p-3">
<ActionButtons apt={apt} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{filteredAppointments.map((apt) => (
<MobileCard
key={apt.id}
title={apt.patient}
subtitle={apt.service}
rows={[
{ label: 'Hora', value: apt.time },
{ label: 'Doctor', value: apt.doctor || '-' },
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge> },
{ label: 'Pago', value: apt.payment_state },
]}
actions={<ActionButtons apt={apt} />}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
title="Nueva cita"
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={createAppointment} loading={submitting}>Guardar cita</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Buscar paciente"
placeholder="Escribe nombre o teléfono..."
value={patientSearch}
onChange={(e) => setPatientSearch(e.target.value)}
/>
<Select
label="Paciente *"
options={patientOptions}
value={form.patient_id}
onChange={(e) => setForm({ ...form, patient_id: e.target.value })}
/>
<Select
label="Servicio *"
options={serviceOptions}
value={form.service_id}
onChange={(e) => setForm({ ...form, service_id: e.target.value })}
/>
<Select
label="Médico"
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={form.doctor_id}
onChange={(e) => setForm({ ...form, doctor_id: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
/>
<Select
label="Hora *"
options={slotOptions}
value={form.time}
onChange={(e) => setForm({ ...form, time: e.target.value })}
disabled={slotsLoading || !form.service_id || !form.date}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
label="Sesiones"
options={[
{ value: '1', label: '1 sesión' },
{ value: '2', label: '2 sesiones' },
{ value: '3', label: '3 sesiones' },
{ value: '4', label: '4 sesiones' },
{ value: '5', label: '5 sesiones' },
{ value: '6', label: '6 sesiones' },
]}
value={String(form.sessions)}
onChange={(e) => setForm({ ...form, sessions: parseInt(e.target.value, 10) })}
/>
<div className="flex items-end">
<p className="text-xs text-theme-muted pb-2">
Si el servicio tiene paquete, se sugerirá el número de sesiones del paquete.
</p>
</div>
</div>
{slotsLoading && <p className="text-xs text-theme-muted">Cargando horarios disponibles...</p>}
<TextArea
label="Notas"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</div>
</Modal>
<Modal
isOpen={expressOpen}
onClose={() => setExpressOpen(false)}
title="Valoración express"
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setExpressOpen(false)}>Cancelar</Button>
<Button onClick={submitExpress} loading={expressSubmitting}>Guardar</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-theme-muted">
Captura rápida de prospecto. Opcionalmente agenda su cita de valoración en el mismo paso.
</p>
<Input
label="Nombre *"
placeholder="Nombre del prospecto"
value={expressForm.name}
onChange={(e) => setExpressForm({ ...expressForm, name: e.target.value })}
/>
<Input
label="Teléfono *"
placeholder="+52 ..."
value={expressForm.phone}
onChange={(e) => setExpressForm({ ...expressForm, phone: e.target.value })}
/>
<TextArea
label="Notas"
placeholder="Motivo de consulta, zona a tratar, etc."
value={expressForm.notes}
onChange={(e) => setExpressForm({ ...expressForm, notes: e.target.value })}
/>
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={expressForm.withAppointment}
onChange={(e) => setExpressForm({ ...expressForm, withAppointment: e.target.checked })}
className="rounded border-theme-border-strong"
/>
Crear cita de valoración ahora
</label>
{expressForm.withAppointment && (
<div className="space-y-4 rounded-xl border border-theme-border p-4 bg-theme-bg">
<Select
label="Servicio *"
options={serviceOptions}
value={expressForm.service_id}
onChange={(e) => setExpressForm({ ...expressForm, service_id: e.target.value })}
/>
<Select
label="Médico"
options={[{ value: '', label: 'Sin preferencia' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={expressForm.doctor_id}
onChange={(e) => setExpressForm({ ...expressForm, doctor_id: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={expressForm.date}
onChange={(e) => setExpressForm({ ...expressForm, date: e.target.value })}
/>
<Input
label="Hora *"
type="time"
value={expressForm.time}
onChange={(e) => setExpressForm({ ...expressForm, time: e.target.value })}
/>
</div>
</div>
)}
</div>
</Modal>
</Layout>
);
};
export default Agenda;

View File

@@ -0,0 +1,241 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
interface ClinicSettings {
name: string;
phone: string;
email: string;
address: string;
website: string;
}
const defaultSettings: ClinicSettings = {
name: 'SKEEN Derma Experts',
phone: '+52 (661) 100-2172',
email: 'hola@skeen.mx',
address: 'Playas de Rosarito, Baja California',
website: 'https://skeen.mx',
};
const STORAGE_KEY = 'skeen_clinic_settings';
const Configuracion: FC = () => {
const [settings, setSettings] = useState<ClinicSettings>(defaultSettings);
const [loading, setLoading] = useState(true);
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) setSettings(JSON.parse(saved));
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
const save = () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
toast.success('Configuración guardada');
} catch {
toast.error('Error al guardar configuración');
}
};
const update = (field: keyof ClinicSettings, value: string) => {
setSettings((prev) => ({ ...prev, [field]: value }));
};
// ---- Tipo de cambio USD/MXN ----
const [tcCurrent, setTcCurrent] = useState<{ rate: number; date: string; source: string } | null>(null);
const [tcInput, setTcInput] = useState('');
const [tcSaving, setTcSaving] = useState(false);
useEffect(() => {
odooApi.getExchangeRate()
.then((res) => {
if (res.current) {
setTcCurrent(res.current);
setTcInput(String(res.current.rate));
}
})
.catch(() => {/* odoo no disponible, ignorar */});
}, []);
const saveTc = async () => {
const rate = parseFloat(tcInput);
if (!rate || rate <= 0) {
toast.error('Ingresa un tipo de cambio válido');
return;
}
setTcSaving(true);
try {
const res = await odooApi.setExchangeRate(rate, 'manual');
setTcCurrent(res.exchange_rate);
toast.success(`Tipo de cambio actualizado: ${rate.toFixed(2)} MXN/USD`);
} catch {
toast.error('No se pudo guardar el tipo de cambio');
} finally {
setTcSaving(false);
}
};
return (
<Layout title="Configuración" subtitle="Ajustes de la clínica">
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Información de la clínica</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(settings.address)}`}
target="_blank"
rel="noreferrer"
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
>
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
<MapPin size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Dirección</p>
<p className="text-sm text-theme-muted truncate">{settings.address}</p>
</div>
</a>
<a
href={`tel:${settings.phone}`}
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
>
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
<Phone size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Teléfono</p>
<p className="text-sm text-theme-muted truncate">{settings.phone}</p>
</div>
</a>
<a
href={`mailto:${settings.email}`}
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
>
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
<Mail size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Email</p>
<p className="text-sm text-theme-muted truncate">{settings.email}</p>
</div>
</a>
<a
href={settings.website}
target="_blank"
rel="noreferrer"
className="p-4 bg-theme-bg rounded-xl flex items-start space-x-3 hover:bg-theme-bg transition"
>
<div className="w-10 h-10 rounded-full bg-theme-surface flex items-center justify-center text-theme-muted shadow-sm shrink-0">
<Globe size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">Sitio web</p>
<p className="text-sm text-theme-muted truncate">{settings.website.replace(/^https?:\/\//, '')}</p>
</div>
</a>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center">
<Building2 size={20} className="mr-2" />
Ajustes
</h3>
{loading ? (
<p className="text-sm text-theme-muted">Cargando...</p>
) : (
<div className="space-y-4">
<Input
label="Nombre de la clínica"
value={settings.name}
onChange={(e) => update('name', e.target.value)}
/>
<Input
label="Teléfono"
value={settings.phone}
onChange={(e) => update('phone', e.target.value)}
/>
<Input
label="Email"
type="email"
value={settings.email}
onChange={(e) => update('email', e.target.value)}
/>
<Input
label="Dirección"
value={settings.address}
onChange={(e) => update('address', e.target.value)}
/>
<Input
label="Sitio web"
value={settings.website}
onChange={(e) => update('website', e.target.value)}
/>
<div className="pt-2">
<Button onClick={save}>
<Save size={16} className="mr-2" />
Guardar ajustes
</Button>
</div>
</div>
)}
</Card.Body>
</Card>
<Card className="lg:col-span-2">
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
<ArrowRightLeft size={20} className="mr-2" />
Tipo de cambio USD / MXN
</h3>
<p className="text-sm text-theme-muted mb-4">
Se usa para convertir ventas en dólares. Vigente:
{tcCurrent ? (
<span className="font-semibold text-theme-heading"> {Number(tcCurrent.rate).toFixed(2)} MXN por 1 USD <span className="font-normal text-theme-muted">({tcCurrent.date})</span></span>
) : (
<span className="text-theme-muted"> sin definir</span>
)}
</p>
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-3 max-w-md">
<div className="flex-1 w-full">
<Input
label="Nuevo tipo de cambio (MXN por 1 USD)"
type="number"
step="0.01"
min="0"
value={tcInput}
onChange={(e) => setTcInput(e.target.value)}
placeholder="17.25"
/>
</div>
<Button onClick={saveTc} disabled={tcSaving}>
<Save size={16} className="mr-2" />
{tcSaving ? 'Guardando...' : 'Actualizar TC'}
</Button>
</div>
</Card.Body>
</Card>
</div>
</Layout>
);
};
export default Configuracion;

View File

@@ -0,0 +1,262 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Plus, Lock, Receipt } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
Modal,
Badge,
EmptyState,
PageHeader,
Skeleton,
MobileCard,
toast,
badgeForCashClosingState,
} from '../components/ui';
import { odooApi, type CashClosing } from '../services/odoo';
const Cortes: FC = () => {
const [closings, setClosings] = useState<CashClosing[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [closeOpen, setCloseOpen] = useState<CashClosing | null>(null);
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0]);
const [openingCash, setOpeningCash] = useState('');
const [closingCash, setClosingCash] = useState('');
const [submitting, setSubmitting] = useState(false);
const load = async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getCashClosings();
if (res.status === 'success') setClosings(res.cash_closings);
} catch (err) {
setError('Error al cargar cortes');
toast.error('Error al cargar cortes');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const create = async () => {
const value = parseFloat(openingCash);
if (Number.isNaN(value) || value < 0) {
toast.error('Ingresa un efectivo inicial válido');
return;
}
try {
setSubmitting(true);
await odooApi.createCashClosing({ date, opening_cash: value });
toast.success('Caja abierta');
setCreateOpen(false);
setOpeningCash('');
await load();
} catch (err) {
toast.error('Error al crear corte');
console.error(err);
} finally {
setSubmitting(false);
}
};
const close = async () => {
if (!closeOpen) return;
const value = parseFloat(closingCash);
if (Number.isNaN(value) || value < 0) {
toast.error('Ingresa un efectivo final válido');
return;
}
try {
setSubmitting(true);
await odooApi.closeCashClosing(closeOpen.id, value);
toast.success('Caja cerrada');
setCloseOpen(null);
setClosingCash('');
await load();
} catch (err) {
toast.error('Error al cerrar caja');
console.error(err);
} finally {
setSubmitting(false);
}
};
const renderUser = (user: string | false) => {
if (!user) return 'Usuario no disponible';
return user;
};
const differenceColor = (diff: number) => {
if (diff > 0) return 'text-theme-heading';
if (diff < 0) return 'text-rose-600';
return 'text-theme-muted';
};
return (
<Layout title="Cortes de Caja" subtitle="Arqueos y cierres">
<PageHeader title="Cortes de Caja" subtitle="Abre y cierra turnos">
<Button onClick={() => setCreateOpen(true)}>
<Plus size={16} className="mr-2" />
Abrir caja
</Button>
</PageHeader>
<Card>
<Card.Body>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={5} className="h-14 w-full" />
) : closings.length === 0 ? (
<EmptyState
title="Sin cortes"
subtitle="No hay cortes de caja registrados."
actionLabel="Abrir caja"
onAction={() => setCreateOpen(true)}
icon={<Receipt size={28} />}
/>
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Fecha</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Usuario</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Apertura</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Ventas</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Diferencia</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{closings.map((c) => (
<tr key={c.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-muted">{c.name}</td>
<td className="p-3 text-sm text-theme-muted">{c.date}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{renderUser(c.user)}</td>
<td className="p-3 text-sm text-theme-heading">${c.opening_cash}</td>
<td className="p-3 text-sm text-theme-heading hidden lg:table-cell">${c.total_sales}</td>
<td className={`p-3 text-sm font-semibold ${differenceColor(c.difference)}`}>
{c.difference > 0 ? '+' : ''}${c.difference}
</td>
<td className="p-3">
<Badge variant={badgeForCashClosingState(c.state)}>{c.state}</Badge>
</td>
<td className="p-3">
{c.state !== 'closed' && (
<Button variant="ghost" size="sm" onClick={() => setCloseOpen(c)} title="Cerrar caja">
<Lock size={16} className="text-theme-heading" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{closings.map((c) => (
<MobileCard
key={c.id}
title={c.name}
subtitle={<Badge variant={badgeForCashClosingState(c.state)}>{c.state}</Badge>}
rows={[
{ label: 'Fecha', value: c.date },
{ label: 'Usuario', value: renderUser(c.user) },
{ label: 'Apertura', value: `$${c.opening_cash}` },
{ label: 'Diferencia', value: `${c.difference > 0 ? '+' : ''}$${c.difference}` },
]}
actions={
c.state !== 'closed' && (
<Button variant="ghost" size="sm" onClick={() => setCloseOpen(c)}>
<Lock size={16} className="text-theme-heading" />
</Button>
)
}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
title="Abrir caja"
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={create} loading={submitting}>Abrir caja</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Fecha"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
<Input
label="Efectivo inicial"
type="number"
min={0}
step={0.01}
value={openingCash}
onChange={(e) => setOpeningCash(e.target.value)}
/>
</div>
</Modal>
<Modal
isOpen={!!closeOpen}
onClose={() => setCloseOpen(null)}
title="Cerrar caja"
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setCloseOpen(null)}>Cancelar</Button>
<Button onClick={close} loading={submitting}>Cerrar caja</Button>
</>
}
>
{closeOpen && (
<div className="space-y-4">
<p className="text-sm text-theme-muted">
Corte: <span className="font-medium text-theme-heading">{closeOpen.name}</span>
</p>
<p className="text-sm text-theme-muted">
Ventas registradas: <span className="font-medium text-theme-heading">${closeOpen.total_sales}</span>
</p>
<Input
label="Efectivo final"
type="number"
min={0}
step={0.01}
value={closingCash}
onChange={(e) => setClosingCash(e.target.value)}
/>
</div>
)}
</Modal>
</Layout>
);
};
export default Cortes;

View File

@@ -0,0 +1,167 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Cake, MessageCircle, Star, Download } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, PageHeader, Button, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { Birthday } from '../services/odoo';
import { downloadCsv } from '../lib/utils';
type Period = 'today' | 'week' | 'month';
const PERIODS: { value: Period; label: string }[] = [
{ value: 'today', label: 'Hoy' },
{ value: 'week', label: 'Próximos 7 días' },
{ value: 'month', label: 'Este mes' },
];
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
const fmtDate = (s: string | null) => {
if (!s) return '—';
const d = new Date(`${s}T12:00:00`);
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
};
const Cumpleanos: FC = () => {
const [period, setPeriod] = useState<Period>('month');
const [items, setItems] = useState<Birthday[]>([]);
const [range, setRange] = useState<{ start: string; end: string } | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
setLoading(true);
odooApi
.getBirthdays(period)
.then((res) => {
if (!active) return;
setItems(res.birthdays || []);
setRange({ start: res.start, end: res.end });
})
.catch(() => {
if (!active) return;
setItems([]);
})
.finally(() => active && setLoading(false));
return () => {
active = false;
};
}, [period]);
const exportBirthdays = () => {
const rows: (string | number | boolean | null | undefined)[][] = [
['Nombre', 'Teléfono', 'Fecha nacimiento', 'Cumple el', 'Cumple años', 'Última visita', 'Total gastado', 'Adeudo', 'VIP'],
];
items.forEach((b) =>
rows.push([
b.name, b.phone, b.birth_date, b.occurs_on, b.turning_age, b.last_visit,
b.total_spent, b.amount_due, b.is_vip ? 'Sí' : 'No',
])
);
downloadCsv(`cumpleaneros-skeen-${period}-${new Date().toISOString().split('T')[0]}.csv`, rows);
toast.success(`CSV descargado (${items.length} cumpleañeros)`);
};
return (
<Layout title="Cumpleañeros" subtitle="Pacientes que cumplen años">
<PageHeader title="Cumpleañeros" subtitle="Pacientes que cumplen años en el periodo">
<Button variant="outline" onClick={exportBirthdays} disabled={items.length === 0}>
<Download size={16} className="mr-2" />
Exportar CSV
</Button>
</PageHeader>
<Card className="mb-4">
<Card.Body>
<div className="flex flex-wrap items-center gap-2">
{PERIODS.map((p) => (
<button
key={p.value}
onClick={() => setPeriod(p.value)}
className={`px-4 py-2 rounded-full text-sm font-medium transition ${
period === p.value
? 'bg-[#1A1A1A] text-white'
: 'bg-theme-bg text-theme-muted hover:bg-theme-border'
}`}
>
{p.label}
</button>
))}
{range && (
<span className="ml-auto text-xs text-theme-muted">
{range.start} {range.end} · {items.length} pacientes
</span>
)}
</div>
</Card.Body>
</Card>
<Card>
<Card.Body noPadding>
{loading ? (
<p className="p-6 text-sm text-theme-muted">Cargando...</p>
) : items.length === 0 ? (
<div className="p-10 text-center text-theme-muted">
<Cake className="mx-auto mb-2" size={28} />
<p className="text-sm">No hay cumpleañeros en este periodo.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Fecha</th>
<th className="text-left px-4 py-3">Paciente</th>
<th className="text-left px-4 py-3">Cumple</th>
<th className="text-left px-4 py-3">Última visita</th>
<th className="text-right px-4 py-3">Total gastado</th>
<th className="text-right px-4 py-3">Adeudo</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{items.map((b) => (
<tr key={b.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{fmtDate(b.occurs_on)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{b.is_vip && <Star size={13} className="text-amber-500 fill-amber-500" />}
<span className="text-theme-heading">{b.name}</span>
</div>
<span className="text-xs text-theme-muted">{b.phone}</span>
</td>
<td className="px-4 py-3 text-theme-muted">{b.turning_age} años</td>
<td className="px-4 py-3 text-theme-muted">{fmtDate(b.last_visit)}</td>
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(b.total_spent)}</td>
<td className={`px-4 py-3 text-right ${b.amount_due > 0 ? 'text-rose-600' : 'text-theme-muted'}`}>
{fmtMoney(b.amount_due)}
</td>
<td className="px-4 py-3 text-right">
{b.phone && (
<a
href={`https://wa.me/${b.phone.replace(/\D/g, '')}?text=${encodeURIComponent(
`¡Feliz cumpleaños ${b.name.split(' ')[0]}! 🎉 Te esperamos en SKEEN Derma Experts.`
)}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-theme-heading hover:text-theme-heading text-xs font-medium"
>
<MessageCircle size={14} /> WhatsApp
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</Layout>
);
};
export default Cumpleanos;

View File

@@ -0,0 +1,572 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useState, useMemo } from 'react';
import {
Calendar,
Users,
DollarSign,
ShoppingCart,
Clock,
Wallet,
Sparkles,
Activity,
TrendingUp,
ArrowRight,
} from 'lucide-react';
import {
AreaChart,
Area,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import Layout from '../components/Layout';
import {
Card,
Button,
Badge,
EmptyState,
PageHeader,
SkeletonStat,
Skeleton,
toast,
badgeForAppointmentState,
} from '../components/ui';
import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint } from '../services/odoo';
import { useTheme } from '../lib/theme';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
interface StatCardProps {
title: string;
value: string | number;
icon: ReactNode;
trend?: string;
loading?: boolean;
}
const StatCard: FC<StatCardProps> = ({ title, value, icon, trend, loading }) => {
if (loading) return <SkeletonStat />;
return (
<Card className="hover:shadow-md transition">
<Card.Body>
<div className="flex items-center justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-theme-success/50 flex items-center justify-center text-theme-heading">
{icon}
</div>
{trend && (
<span className="text-xs font-medium text-theme-heading bg-[#CAE2C1] px-2 py-0.5 rounded-full">
{trend}
</span>
)}
</div>
<p className="text-sm text-theme-muted mb-1">{title}</p>
<p className="text-2xl font-heading font-semibold text-theme-heading">{value}</p>
</Card.Body>
</Card>
);
};
const Dashboard: FC = () => {
const { theme } = useTheme();
const isHomeNest = theme === 'homenest';
const [stats, setStats] = useState<DashboardStats | null>(null);
const [chartData, setChartData] = useState<ChartPoint[]>([]);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [loadingStats, setLoadingStats] = useState(true);
const [loadingChart, setLoadingChart] = useState(true);
const [loadingAppointments, setLoadingAppointments] = useState(true);
const [loadingServices, setLoadingServices] = useState(true);
const [topServices, setTopServices] = useState<{ service: string; qty: number; revenue: number }[]>([]);
const [aptReport, setAptReport] = useState<{ total: number; by_state: Record<string, number> } | null>(null);
const [loadingExtras, setLoadingExtras] = useState(true);
useEffect(() => {
const loadStats = async () => {
try {
setLoadingStats(true);
const res = await odooApi.getDashboard();
if (res.status === 'success' && res.stats) {
setStats(res.stats);
}
} catch (err) {
toast.error('Error al cargar estadísticas del dashboard');
console.error(err);
} finally {
setLoadingStats(false);
}
};
const loadChart = async () => {
try {
setLoadingChart(true);
const res = await odooApi.getWeeklyChart();
if (res.status === 'success' && res.data) {
setChartData(res.data);
}
} catch (err) {
toast.error('Error al cargar gráfica semanal');
console.error(err);
} finally {
setLoadingChart(false);
}
};
const loadAppointments = async () => {
try {
setLoadingAppointments(true);
const today = new Date().toISOString().split('T')[0];
const res = await odooApi.getAppointments({ date: today, limit: '5' });
if (res.status === 'success' && res.appointments) {
setAppointments(res.appointments.slice(0, 5));
}
} catch (err) {
toast.error('Error al cargar próximas citas');
console.error(err);
} finally {
setLoadingAppointments(false);
}
};
const loadServices = async () => {
try {
setLoadingServices(true);
const res = await odooApi.getServices();
if (res.status === 'success' && res.services) {
setServices(res.services.slice(0, 6));
}
} catch (err) {
toast.error('Error al cargar servicios');
console.error(err);
} finally {
setLoadingServices(false);
}
};
const loadExtras = async () => {
try {
setLoadingExtras(true);
const start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
const end = new Date().toISOString().split('T')[0];
const [topRes, aptRes] = await Promise.all([
odooApi.getTopServices(start, end, 6),
odooApi.getAppointmentsReport(start, end),
]);
if (topRes.status === 'success') setTopServices(topRes.services || []);
if (aptRes.status === 'success') setAptReport({ total: aptRes.total, by_state: aptRes.by_state });
} catch (err) {
console.error(err);
} finally {
setLoadingExtras(false);
}
};
loadStats();
loadChart();
loadAppointments();
loadServices();
loadExtras();
}, []);
const statsConfig = useMemo(
() => [
{
title: 'Citas Hoy',
value: stats?.appointments_today ?? 0,
icon: <Calendar size={20} />,
trend: stats ? `${stats.appointments_month} este mes` : undefined,
},
{
title: 'Pacientes',
value: stats?.total_patients ?? 0,
icon: <Users size={20} />,
trend: stats ? `+${stats.new_patients_month} nuevos` : undefined,
},
{
title: 'Ingresos Mes',
value: stats ? formatCurrency(stats.revenue_month) : '$0',
icon: <DollarSign size={20} />,
},
{
title: 'Ventas Hoy',
value: stats?.sales_today ?? 0,
icon: <ShoppingCart size={20} />,
},
{
title: 'Pendientes',
value: stats?.pending_payments ?? 0,
icon: <Clock size={20} />,
},
{
title: 'Puntos Monedero',
value: stats?.total_wallet_points ?? 0,
icon: <Wallet size={20} />,
},
],
[stats]
);
const activityItems = useMemo(() => {
return appointments.slice(0, 4).map((apt) => ({
id: apt.id,
title: `${apt.patient}${apt.service}`,
subtitle: `${apt.date} ${apt.time}`,
state: apt.state,
}));
}, [appointments]);
const attendanceData = useMemo(() => {
if (!aptReport) return [] as { name: string; value: number; color: string }[];
const s = aptReport.by_state || {};
const pick = (...keys: string[]) => keys.reduce((a, k) => a + (s[k] || 0), 0);
return [
{ name: 'Completadas', value: pick('done'), color: '#CAE2C1' },
{ name: 'Confirmadas', value: pick('confirmed', 'arrived', 'in_progress'), color: '#A4CCE2' },
{ name: 'Pendientes', value: pick('pending'), color: '#EDA588' },
{ name: 'No show', value: pick('no_show'), color: '#E57373' },
{ name: 'Canceladas', value: pick('cancelled'), color: '#E5E0DC' },
].filter((d) => d.value > 0);
}, [aptReport]);
const noShowRate = useMemo(() => {
if (!aptReport || !aptReport.total) return 0;
return Math.round(((aptReport.by_state?.no_show || 0) / aptReport.total) * 100);
}, [aptReport]);
return (
<Layout title="Dashboard" subtitle="Panel de control de SKEEN Derma Experts">
{isHomeNest ? (
<div className="rounded-3xl p-6 sm:p-10 mb-8 border shadow-soft" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium mb-4" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
<Sparkles size={14} /> Bienvenido de vuelta
</div>
<h1 className="font-display text-3xl sm:text-4xl lg:text-5xl mb-3" style={{ color: 'var(--text-heading)' }}>SKEEN Derma Experts</h1>
<p className="text-base sm:text-lg leading-relaxed" style={{ color: 'var(--text-muted)' }}>
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con calidez y confianza.
</p>
</div>
<div className="flex flex-wrap gap-3 shrink-0">
<Button variant="primary" size="md">
<Calendar size={16} className="mr-2" /> Nueva cita
</Button>
<Button variant="outline" size="md">
<TrendingUp size={16} className="mr-2" /> Ver reportes
</Button>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-8 pt-8" style={{ borderTop: '1px solid var(--border)' }}>
{statsConfig.slice(0, 4).map((s) => (
<div key={s.title} className="text-center sm:text-left">
<p className="text-xs mb-1" style={{ color: 'var(--text-muted)' }}>{s.title}</p>
<p className="text-xl sm:text-2xl font-heading" style={{ color: 'var(--text-heading)' }}>{loadingStats ? '-' : s.value}</p>
</div>
))}
</div>
</div>
) : (
<>
<PageHeader title="Dashboard" subtitle="Resumen general de la clínica">
<Button variant="outline" size="sm">
<Sparkles size={16} className="mr-2" />
Bienvenido
</Button>
</PageHeader>
<div className="bg-gradient-to-r from-[#EDA588] to-[#C7BCDD] rounded-2xl p-6 sm:p-8 text-white mb-6 sm:mb-8 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="font-heading text-2xl sm:text-3xl mb-2">SKEEN Derma Experts</h1>
<p className="text-white/90 text-sm sm:text-base max-w-xl">
Ciencia, estética y cuidado personalizado en cada tratamiento. Gestiona tu clínica con confianza.
</p>
</div>
<div className="shrink-0">
<Button variant="secondary" size="sm" className="bg-theme-surface/20 text-white border-white/30 hover:bg-theme-surface/30">
<TrendingUp size={16} className="mr-2" />
Ver reportes
</Button>
</div>
</div>
</div>
</>
)}
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-6 sm:mb-8">
{statsConfig.map((s) => (
<StatCard
key={s.title}
title={s.title}
value={s.value}
icon={s.icon}
trend={s.trend}
loading={loadingStats}
/>
))}
</div>
{/* Chart + appointments */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Actividad semanal</h3>
<p className="text-xs text-theme-muted">Citas e ingresos de los últimos 7 días</p>
</div>
</Card.Header>
<Card.Body>
{loadingChart ? (
<Skeleton count={8} className="h-6 w-full" />
) : chartData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay información para la gráfica semanal." icon={<TrendingUp size={28} />} />
) : (
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<defs>
<linearGradient id="colorAppointments" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#EDA588" stopOpacity={0.3} />
<stop offset="95%" stopColor="#EDA588" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#CAE2C1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#CAE2C1" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#E5E0DC" />
<XAxis dataKey="name" tick={{ fontSize: 12, fill: '#6B6B6B' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: '#6B6B6B' }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Area type="monotone" dataKey="appointments" stroke="#EDA588" fillOpacity={1} fill="url(#colorAppointments)" strokeWidth={2} />
<Area type="monotone" dataKey="revenue" stroke="#CAE2C1" fillOpacity={1} fill="url(#colorRevenue)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</Card.Body>
</Card>
<Card className="flex flex-col">
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Próximas citas</h3>
<p className="text-xs text-theme-muted">Citas programadas para hoy</p>
</div>
<Button variant="ghost" size="sm" className="hidden sm:inline-flex">
Ver agenda
<ArrowRight size={14} className="ml-1" />
</Button>
</Card.Header>
<Card.Body className="flex-1">
{loadingAppointments ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : appointments.length === 0 ? (
<EmptyState title="Sin citas" subtitle="No hay citas programadas para hoy." icon={<Calendar size={28} />} />
) : (
<div className="space-y-3">
{appointments.map((apt) => (
<div
key={apt.id}
className="flex items-center justify-between p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{apt.patient}</p>
<p className="text-xs text-theme-muted truncate">{apt.service} {apt.time}</p>
</div>
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
</div>
))}
</div>
)}
</Card.Body>
</Card>
</div>
{/* Top servicios + Asistencia del mes */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-6 sm:mb-8">
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Top servicios del mes</h3>
<p className="text-xs text-theme-muted">Por ingresos generados</p>
</div>
</Card.Header>
<Card.Body>
{loadingExtras ? (
<Skeleton count={6} className="h-6 w-full" />
) : topServices.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay ventas en el mes." icon={<TrendingUp size={28} />} />
) : (
<div className="h-[280px] w-full">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={topServices} layout="vertical" margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#E5E0DC" />
<XAxis type="number" tick={{ fontSize: 11, fill: '#6B6B6B' }} axisLine={false} tickLine={false} tickFormatter={(v: number) => formatCurrency(v)} />
<YAxis
type="category"
dataKey="service"
width={150}
tick={{ fontSize: 11, fill: '#1A1A1A' }}
axisLine={false}
tickLine={false}
tickFormatter={(v: string) => (v.length > 22 ? `${v.slice(0, 22)}` : v)}
/>
<Tooltip
formatter={(v) => formatCurrency(Number(v))}
contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Bar dataKey="revenue" fill="#EDA588" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Asistencia del mes</h3>
<p className="text-xs text-theme-muted">
{aptReport ? `${aptReport.total} citas · tasa no-show ${noShowRate}%` : 'Distribución por estado'}
</p>
</div>
</Card.Header>
<Card.Body>
{loadingExtras ? (
<Skeleton count={6} className="h-6 w-full" />
) : attendanceData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay citas en el mes." icon={<Calendar size={28} />} />
) : (
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="h-[240px] w-full sm:w-1/2">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie data={attendanceData} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={60} outerRadius={95} paddingAngle={2}>
{attendanceData.map((d, i) => (
<Cell key={`cell-${i}`} fill={d.color} />
))}
</Pie>
<Tooltip contentStyle={{ borderRadius: 12, border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 w-full space-y-2">
{attendanceData.map((d) => (
<div key={d.name} className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-theme-muted">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: d.color }} />
{d.name}
</span>
<span className="font-medium text-theme-heading">{d.value}</span>
</div>
))}
</div>
</div>
)}
</Card.Body>
</Card>
</div>
{/* Services + activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Servicios destacados</h3>
<p className="text-xs text-theme-muted">Catálogo activo de tratamientos</p>
</div>
</Card.Header>
<Card.Body>
{loadingServices ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : services.length === 0 ? (
<EmptyState title="Sin servicios" subtitle="No hay servicios registrados." icon={<Sparkles size={28} />} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{services.map((s) => (
<div
key={s.id}
className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
>
<div
className="w-10 h-10 rounded-lg shrink-0"
style={{ backgroundColor: s.color || '#78716c' }}
/>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{s.name}</p>
<p className="text-xs text-theme-muted">${s.price} {s.duration_min} min</p>
</div>
</div>
))}
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Header>
<div>
<h3 className="font-heading text-lg text-theme-heading">Actividad reciente</h3>
<p className="text-xs text-theme-muted">Últimos movimientos en la clínica</p>
</div>
</Card.Header>
<Card.Body>
{loadingAppointments ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : activityItems.length === 0 ? (
<EmptyState title="Sin actividad" subtitle="No hay movimientos recientes." icon={<Activity size={28} />} />
) : (
<div className="space-y-3">
{activityItems.map((item) => (
<div
key={item.id}
className="flex items-start gap-3 p-3 border border-theme-border-strong rounded-xl hover:bg-theme-bg transition"
>
<div className="w-9 h-9 rounded-full bg-theme-border flex items-center justify-center text-theme-muted shrink-0 mt-0.5">
<Activity size={16} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{item.title}</p>
<p className="text-xs text-theme-muted">{item.subtitle}</p>
</div>
<Badge variant={badgeForAppointmentState(item.state)} className="shrink-0">
{item.state}
</Badge>
</div>
))}
</div>
)}
</Card.Body>
</Card>
</div>
</Layout>
);
};
export default Dashboard;

View File

@@ -0,0 +1,315 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, Select, Modal, PageHeader, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary } from '../services/odoo';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
const LEVEL_META: Record<InventoryLevel, { label: string; cls: string }> = {
out: { label: 'Sin existencias', cls: 'bg-rose-100 text-rose-700' },
critical: { label: 'Crítico', cls: 'bg-orange-100 text-orange-700' },
low: { label: 'Bajo', cls: 'bg-theme-bg text-theme-warning-text' },
optimal: { label: 'Óptimo', cls: 'bg-theme-success text-theme-heading' },
};
const MOVE_META: Record<InventoryMoveType, { label: string; sign: string; cls: string }> = {
compra: { label: 'Compra', sign: '+', cls: 'text-theme-heading' },
venta: { label: 'Venta', sign: '', cls: 'text-theme-muted' },
baja: { label: 'Baja', sign: '', cls: 'text-rose-600' },
ajuste: { label: 'Ajuste', sign: '=', cls: 'text-sky-600' },
};
const KIND_OPTIONS = [
{ value: '', label: 'Todos' },
{ value: 'producto', label: 'Productos' },
{ value: 'consumible', label: 'Consumibles' },
];
const LEVEL_OPTIONS = [
{ value: '', label: 'Todos los niveles' },
{ value: 'optimal', label: 'Óptimo' },
{ value: 'low', label: 'Bajo' },
{ value: 'critical', label: 'Crítico' },
{ value: 'out', label: 'Sin existencias' },
];
const MOVE_OPTIONS = [
{ value: 'compra', label: 'Compra (entrada)' },
{ value: 'venta', label: 'Venta (salida)' },
{ value: 'baja', label: 'Baja (merma)' },
{ value: 'ajuste', label: 'Ajuste (fijar conteo físico)' },
];
const emptyItem: Partial<InventoryItem> = {
name: '', kind: 'producto', sku: '', category: '', unit: 'pieza',
qty: 0, qty_optimal: 0, qty_min: 0, cost: 0, expiry_date: null, notes: '',
};
const Inventario: FC = () => {
const [items, setItems] = useState<InventoryItem[]>([]);
const [summary, setSummary] = useState<InventorySummary>({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 });
const [loading, setLoading] = useState(true);
const [kind, setKind] = useState<string>('');
const [level, setLevel] = useState<string>('');
const [search, setSearch] = useState('');
const [newOpen, setNewOpen] = useState(false);
const [newForm, setNewForm] = useState<Partial<InventoryItem>>(emptyItem);
const [savingNew, setSavingNew] = useState(false);
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
const [moveType, setMoveType] = useState<InventoryMoveType>('compra');
const [moveQty, setMoveQty] = useState('');
const [moveRef, setMoveRef] = useState('');
const [savingMove, setSavingMove] = useState(false);
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null);
const [moves, setMoves] = useState<InventoryMove[]>([]);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await odooApi.getInventory({
...(kind ? { kind: kind as InventoryKind } : {}),
...(level ? { level: level as InventoryLevel } : {}),
...(search ? { search } : {}),
});
setItems(res.items || []);
setSummary(res.summary);
} catch {
toast.error('No se pudo cargar el inventario');
} finally {
setLoading(false);
}
}, [kind, level, search]);
useEffect(() => { load(); }, [load]);
const createItem = async () => {
if (!newForm.name) { toast.error('El nombre es obligatorio'); return; }
setSavingNew(true);
try {
await odooApi.createInventoryItem(newForm);
toast.success('Item creado');
setNewOpen(false);
setNewForm(emptyItem);
load();
} catch {
toast.error('Error al crear el item');
} finally {
setSavingNew(false);
}
};
const openAdjust = (it: InventoryItem) => {
setAdjustItem(it);
setMoveType('compra');
setMoveQty('');
setMoveRef('');
};
const submitMove = async () => {
if (!adjustItem) return;
const q = parseFloat(moveQty);
if (isNaN(q) || q < 0) { toast.error('Cantidad inválida'); return; }
setSavingMove(true);
try {
await odooApi.createInventoryMove(adjustItem.id, { type: moveType, qty: q, reference: moveRef });
toast.success('Movimiento registrado');
setAdjustItem(null);
load();
} catch {
toast.error('Error al registrar el movimiento');
} finally {
setSavingMove(false);
}
};
const openHistory = async (it: InventoryItem) => {
setHistoryItem(it);
try {
const res = await odooApi.getInventoryMoves(it.id);
setMoves(res.moves || []);
} catch {
setMoves([]);
}
};
const lvl = summary.levels;
return (
<Layout title="Inventario" subtitle="Productos y consumibles">
<PageHeader
title="Inventario y consumibles"
subtitle="Existencias, niveles y movimientos (compras, ventas, bajas, ajustes)"
>
<Button onClick={() => setNewOpen(true)}>
<Plus size={16} className="mr-2" /> Nuevo item
</Button>
</PageHeader>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4">
<Card><Card.Body>
<p className="text-xs text-theme-muted">Valor total</p>
<p className="text-xl font-semibold text-theme-heading">{fmtMoney(summary.total_value)}</p>
<p className="text-xs text-theme-muted">{summary.count} items</p>
</Card.Body></Card>
<Card><Card.Body>
<div className="flex items-center gap-2"><PackageCheck size={16} className="text-theme-heading" /><p className="text-xs text-theme-muted">Óptimo</p></div>
<p className="text-xl font-semibold text-theme-heading">{lvl.optimal}</p>
</Card.Body></Card>
<Card><Card.Body>
<div className="flex items-center gap-2"><AlertTriangle size={16} className="text-amber-600" /><p className="text-xs text-theme-muted">Bajo / Crítico</p></div>
<p className="text-xl font-semibold text-theme-warning-text">{lvl.low + lvl.critical}</p>
</Card.Body></Card>
<Card><Card.Body>
<div className="flex items-center gap-2"><Boxes size={16} className="text-rose-600" /><p className="text-xs text-theme-muted">Sin existencias</p></div>
<p className="text-xl font-semibold text-rose-700">{lvl.out}</p>
</Card.Body></Card>
</div>
{/* Filtros */}
<Card className="mb-4"><Card.Body>
<div className="flex flex-col lg:flex-row gap-3">
<div className="flex flex-wrap gap-2">
{KIND_OPTIONS.map((k) => (
<button key={k.value} onClick={() => setKind(k.value)}
className={`px-4 py-2 rounded-full text-sm font-medium transition ${kind === k.value ? 'bg-[#1A1A1A] text-white' : 'bg-theme-bg text-theme-muted hover:bg-theme-border'}`}>
{k.label}
</button>
))}
</div>
<div className="flex-1 min-w-[200px]">
<Input placeholder="Buscar por nombre, SKU o categoría" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="w-full lg:w-56">
<Select options={LEVEL_OPTIONS} value={level} onChange={(e) => setLevel(e.target.value)} />
</div>
</div>
</Card.Body></Card>
{/* Tabla */}
<Card><Card.Body noPadding>
{loading ? (
<p className="p-6 text-sm text-theme-muted">Cargando...</p>
) : items.length === 0 ? (
<div className="p-10 text-center text-theme-muted">
<Boxes className="mx-auto mb-2" size={28} />
<p className="text-sm">No hay items. Crea el primero con Nuevo item.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Item</th>
<th className="text-left px-4 py-3">Tipo</th>
<th className="text-right px-4 py-3">Existencia</th>
<th className="text-left px-4 py-3">Nivel</th>
<th className="text-right px-4 py-3">Costo</th>
<th className="text-right px-4 py-3">Valor</th>
<th className="text-left px-4 py-3">Caducidad</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{items.map((it) => (
<tr key={it.id} className="hover:bg-theme-bg">
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{it.name}</p>
<p className="text-xs text-theme-muted">{[it.sku, it.category].filter(Boolean).join(' · ') || '—'}</p>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded-full ${it.kind === 'consumible' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'}`}>
{it.kind === 'consumible' ? 'Consumible' : 'Producto'}
</span>
</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{it.qty} <span className="text-xs text-theme-muted">{it.unit}</span></td>
<td className="px-4 py-3"><span className={`text-xs px-2 py-1 rounded-full ${LEVEL_META[it.stock_level].cls}`}>{LEVEL_META[it.stock_level].label}</span></td>
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.cost)}</td>
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(it.inventory_value)}</td>
<td className="px-4 py-3 text-theme-muted">{it.expiry_date || '—'}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => openHistory(it)}><History size={14} /></Button>
<Button size="sm" onClick={() => openAdjust(it)}>Ajustar</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body></Card>
{/* Modal nuevo item */}
<Modal isOpen={newOpen} onClose={() => setNewOpen(false)} title="Nuevo item de inventario" maxWidth="lg"
footer={<>
<Button variant="ghost" onClick={() => setNewOpen(false)}>Cancelar</Button>
<Button onClick={createItem} loading={savingNew}>Guardar</Button>
</>}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2"><Input label="Nombre" value={newForm.name || ''} onChange={(e) => setNewForm({ ...newForm, name: e.target.value })} /></div>
<Select label="Tipo" options={[{ value: 'producto', label: 'Producto' }, { value: 'consumible', label: 'Consumible' }]} value={newForm.kind || 'producto'} onChange={(e) => setNewForm({ ...newForm, kind: e.target.value as InventoryKind })} />
<Input label="Unidad" value={newForm.unit || ''} onChange={(e) => setNewForm({ ...newForm, unit: e.target.value })} placeholder="pieza / caja / vial" />
<Input label="SKU / Código" value={newForm.sku || ''} onChange={(e) => setNewForm({ ...newForm, sku: e.target.value })} />
<Input label="Línea / Categoría" value={newForm.category || ''} onChange={(e) => setNewForm({ ...newForm, category: e.target.value })} />
<Input label="Existencia inicial" type="number" min="0" step="0.01" value={String(newForm.qty ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty: parseFloat(e.target.value) || 0 })} />
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(newForm.cost ?? 0)} onChange={(e) => setNewForm({ ...newForm, cost: parseFloat(e.target.value) || 0 })} />
<Input label="Stock mínimo (crítico)" type="number" min="0" step="0.01" value={String(newForm.qty_min ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_min: parseFloat(e.target.value) || 0 })} />
<Input label="Nivel óptimo" type="number" min="0" step="0.01" value={String(newForm.qty_optimal ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_optimal: parseFloat(e.target.value) || 0 })} />
{newForm.kind === 'consumible' && (
<Input label="Caducidad" type="date" value={newForm.expiry_date || ''} onChange={(e) => setNewForm({ ...newForm, expiry_date: e.target.value || null })} />
)}
</div>
</Modal>
{/* Modal ajustar / movimiento */}
<Modal isOpen={!!adjustItem} onClose={() => setAdjustItem(null)} title={adjustItem ? `Ajustar: ${adjustItem.name}` : 'Ajustar'} maxWidth="md"
footer={<>
<Button variant="ghost" onClick={() => setAdjustItem(null)}>Cancelar</Button>
<Button onClick={submitMove} loading={savingMove}>Registrar</Button>
</>}>
{adjustItem && (
<div className="space-y-4">
<p className="text-sm text-theme-muted">Existencia actual: <span className="font-semibold text-theme-heading">{adjustItem.qty} {adjustItem.unit}</span></p>
<Select label="Tipo de movimiento" options={MOVE_OPTIONS} value={moveType} onChange={(e) => setMoveType(e.target.value as InventoryMoveType)} />
<Input label={moveType === 'ajuste' ? 'Nuevo conteo (existencia final)' : 'Cantidad'} type="number" min="0" step="0.01" value={moveQty} onChange={(e) => setMoveQty(e.target.value)} />
<Input label="Referencia (opcional)" value={moveRef} onChange={(e) => setMoveRef(e.target.value)} placeholder="OC, folio, nota..." />
{moveType === 'ajuste' && <p className="text-xs text-sky-700 bg-sky-50 rounded-lg p-2">El ajuste fija la existencia al valor capturado (conteo físico).</p>}
</div>
)}
</Modal>
{/* Modal historial */}
<Modal isOpen={!!historyItem} onClose={() => setHistoryItem(null)} title={historyItem ? `Movimientos: ${historyItem.name}` : 'Movimientos'} maxWidth="lg">
{moves.length === 0 ? (
<p className="text-sm text-theme-muted">Sin movimientos registrados.</p>
) : (
<div className="space-y-2">
{moves.map((m) => (
<div key={m.id} className="flex items-center justify-between p-3 bg-theme-bg rounded-xl text-sm">
<div className="flex items-center gap-2">
{m.type === 'compra' ? <ArrowDownToLine size={16} className="text-theme-heading" /> : <ArrowUpFromLine size={16} className="text-theme-muted" />}
<div>
<p className={`font-medium ${MOVE_META[m.type].cls}`}>{MOVE_META[m.type].label} {MOVE_META[m.type].sign}{m.qty}</p>
<p className="text-xs text-theme-muted">{m.date}{m.reference ? ` · ${m.reference}` : ''}</p>
</div>
</div>
<p className="text-xs text-theme-muted">{m.before_qty} <span className="font-semibold text-theme-heading">{m.after_qty}</span></p>
</div>
))}
</div>
)}
</Modal>
</Layout>
);
};
export default Inventario;

View File

@@ -0,0 +1,119 @@
import type { FC, FormEvent } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { LogIn } from 'lucide-react';
import { Button, Input } from '../components/ui';
import { useAuth } from '../lib/auth';
import { useTheme } from '../lib/theme';
const LOGO_SKEEN = '/skeen-brand/logos/Logo%20Completo%20Blanco.png';
const LOGO_HOMENEST = '/skeen-brand/logos/Logo%20Completo%20Negro.png';
const Login: FC = () => {
const { login } = useAuth();
const { theme } = useTheme();
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isHomeNest = theme === 'homenest';
const logo = isHomeNest ? LOGO_HOMENEST : LOGO_SKEEN;
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!username.trim() || !password) {
setError('Escribe usuario y contraseña');
return;
}
try {
setSubmitting(true);
await login(username.trim(), password);
navigate('/', { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo iniciar sesión');
} finally {
setSubmitting(false);
}
};
return (
<div
className="min-h-screen flex items-center justify-center p-4 relative overflow-hidden transition-colors"
style={{ backgroundColor: isHomeNest ? 'var(--bg)' : 'var(--color-dark)' }}
>
{/* Círculos decorativos de la paleta */}
<div
className="absolute top-[-10%] right-[-5%] w-72 h-72 rounded-full blur-3xl"
style={{ backgroundColor: isHomeNest ? 'var(--color-coral)' : 'var(--color-coral)', opacity: isHomeNest ? 0.2 : 0.2 }}
/>
<div
className="absolute bottom-[-10%] left-[-5%] w-80 h-80 rounded-full blur-3xl"
style={{ backgroundColor: isHomeNest ? 'var(--color-mint)' : 'var(--color-mint)', opacity: isHomeNest ? 0.3 : 0.15 }}
/>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 rounded-full blur-3xl"
style={{ backgroundColor: isHomeNest ? 'var(--color-sky)' : 'var(--color-lilac)', opacity: isHomeNest ? 0.4 : 0.1 }}
/>
<div className="w-full max-w-sm relative z-10">
<div className="text-center mb-10">
<img
src={logo}
alt="SKEEN Derma Experts"
className="h-10 w-auto object-contain mx-auto mb-6"
/>
<h1 className="font-display text-3xl mb-2 tracking-wide" style={{ color: isHomeNest ? 'var(--text-heading)' : '#fff' }}>Bienvenido</h1>
<p className="text-sm" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.6)' }}>Inicia sesión para continuar</p>
</div>
<form
onSubmit={onSubmit}
className="rounded-3xl p-7 space-y-5 border"
style={{
backgroundColor: isHomeNest ? 'var(--surface)' : 'rgba(255,255,255,0.05)',
borderColor: isHomeNest ? 'var(--border)' : 'rgba(255,255,255,0.1)',
backdropFilter: isHomeNest ? 'none' : 'blur(8px)',
}}
>
<Input
label="Usuario"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
inputClassName={isHomeNest
? 'bg-theme-bg border-theme-border-strong text-theme-heading placeholder:text-theme-muted/60 focus:border-theme-accent focus:ring-theme-accent/30'
: 'bg-theme-surface/10 border-white/10 text-white placeholder:text-white/40 focus:border-theme-accent focus:ring-theme-accent/30'
}
labelClassName={isHomeNest ? 'text-theme-muted' : 'text-white/70'}
/>
<Input
label="Contraseña"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
inputClassName={isHomeNest
? 'bg-theme-bg border-theme-border-strong text-theme-heading placeholder:text-theme-muted/60 focus:border-theme-accent focus:ring-theme-accent/30'
: 'bg-theme-surface/10 border-white/10 text-white placeholder:text-white/40 focus:border-theme-accent focus:ring-theme-accent/30'
}
labelClassName={isHomeNest ? 'text-theme-muted' : 'text-white/70'}
/>
{error && <p className="text-sm" style={{ color: 'var(--color-coral)' }}>{error}</p>}
<Button type="submit" className="w-full" loading={submitting}>
<LogIn size={16} className="mr-2" />
Entrar
</Button>
</form>
<p className="text-center text-xs mt-6" style={{ color: isHomeNest ? 'var(--text-muted)' : 'rgba(255,255,255,0.3)' }}>
SKEEN Derma Experts · Playas de Rosarito, B.C.
</p>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,170 @@
import type { FC } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, toast } from '../components/ui';
import { odooApi, type Doctor } from '../services/odoo';
const medicalTerms = ['doctor', 'dra', 'médico', 'medico', 'especialista', 'dermatólogo', 'dermatologo', 'skin', 'clínico', 'clinico'];
const isMedicalTitle = (jobTitle?: string) => {
if (!jobTitle) return false;
const normalized = jobTitle.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
return medicalTerms.some((term) => normalized.includes(term));
};
const Medicos: FC = () => {
const [employees, setEmployees] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getDoctors();
if (res.status === 'success') setEmployees(res.doctors);
} catch (err) {
setError('Error al cargar médicos');
toast.error('Error al cargar médicos');
console.error(err);
} finally {
setLoading(false);
}
};
load();
}, []);
const doctors = useMemo(() => {
const medical = employees.filter((d) => isMedicalTitle(d.job_title));
return medical.length > 0 ? medical : employees;
}, [employees]);
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
const [savingPct, setSavingPct] = useState<number | null>(null);
const pctValue = (d: Doctor) => (pctDraft[d.id] ?? String(d.commission_pct ?? 0));
const savePct = async (d: Doctor) => {
const raw = pctDraft[d.id];
const pct = parseFloat(raw ?? String(d.commission_pct ?? 0));
if (Number.isNaN(pct) || pct < 0 || pct > 100) {
toast.error('Porcentaje inválido (0-100)');
return;
}
setSavingPct(d.id);
try {
await odooApi.updateDoctor(d.id, { commission_pct: pct });
setEmployees((prev) => prev.map((e) => (e.id === d.id ? { ...e, commission_pct: pct } : e)));
setPctDraft((prev) => { const n = { ...prev }; delete n[d.id]; return n; });
toast.success(`Comisión de ${d.name}: ${pct}%`);
} catch {
toast.error('No se pudo guardar la comisión');
} finally {
setSavingPct(null);
}
};
return (
<Layout title="Médicos" subtitle="Equipo médico">
<PageHeader title="Médicos" subtitle="Especialistas de la clínica" />
<Card>
<Card.Body>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={5} className="h-14 w-full" />
) : doctors.length === 0 ? (
<EmptyState title="Sin médicos" subtitle="No se encontraron médicos en el equipo." icon={<Stethoscope size={28} />} />
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puesto</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Teléfono</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Email</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% Comisión</th>
</tr>
</thead>
<tbody className="divide-y">
{doctors.map((d) => (
<tr key={d.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-medium text-theme-heading">{d.name}</td>
<td className="p-3">
<Badge variant="default" className="inline-flex items-center">
<Briefcase size={12} className="mr-1" />
{d.job_title || 'Sin puesto'}
</Badge>
</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
<a href={`tel:${d.work_phone}`} className="hover:text-theme-heading">
<Phone size={14} className="inline mr-1 text-theme-muted" />
{d.work_phone || '-'}
</a>
</td>
<td className="p-3 text-sm text-theme-muted hidden lg:table-cell">
{d.work_email ? (
<a href={`mailto:${d.work_email}`} className="hover:text-theme-heading">
<Mail size={14} className="inline mr-1 text-theme-muted" />
{d.work_email}
</a>
) : (
'-'
)}
</td>
<td className="p-3">
<div className="flex items-center gap-2 max-w-[170px]">
<Percent size={14} className="text-theme-muted shrink-0" />
<Input
type="number"
min="0"
max="100"
step="0.5"
value={pctValue(d)}
onChange={(e) => setPctDraft((prev) => ({ ...prev, [d.id]: e.target.value }))}
className="max-w-[92px]"
/>
<Button size="sm" variant="outline" loading={savingPct === d.id} onClick={() => savePct(d)} title="Guardar %">
<Save size={14} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{doctors.map((d) => (
<MobileCard
key={d.id}
title={d.name}
subtitle={
<Badge variant="default" className="inline-flex items-center">
<Briefcase size={12} className="mr-1" />
{d.job_title || 'Sin puesto'}
</Badge>
}
rows={[
{ label: 'Teléfono', value: d.work_phone || '-' },
{ label: 'Email', value: d.work_email || '-' },
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
]}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
</Layout>
);
};
export default Medicos;

View File

@@ -0,0 +1,265 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Search, Plus, Minus, Wallet } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
TextArea,
Modal,
EmptyState,
PageHeader,
Skeleton,
MobileCard,
toast,
} from '../components/ui';
import { odooApi, type Wallet as WalletType } from '../services/odoo';
type TxType = 'accrual' | 'redemption';
const Monedero: FC = () => {
const [wallets, setWallets] = useState<WalletType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [modalOpen, setModalOpen] = useState(false);
const [selected, setSelected] = useState<WalletType | null>(null);
const [txType, setTxType] = useState<TxType>('accrual');
const [points, setPoints] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getWallets({ search, page, page_size: pageSize });
if (res.status === 'success') {
setWallets(res.wallets);
setTotal(res.total ?? 0);
setTotalPages(res.total_pages ?? 0);
}
} catch (err) {
setError('Error al cargar monederos');
toast.error('Error al cargar monederos');
console.error(err);
} finally {
setLoading(false);
}
}, [search, page, pageSize]);
useEffect(() => {
setPage(1);
}, [search]);
useEffect(() => {
load();
}, [load]);
const openModal = (w: WalletType, type: TxType) => {
setSelected(w);
setTxType(type);
setPoints('');
setDescription('');
setModalOpen(true);
};
const submit = async () => {
if (!selected) return;
const value = parseFloat(points);
if (Number.isNaN(value) || value <= 0) {
toast.error('Ingresa una cantidad válida de puntos');
return;
}
if (txType === 'redemption' && value > selected.points) {
toast.error('No puede redimir más puntos de los disponibles');
return;
}
try {
setSubmitting(true);
await odooApi.walletTransaction({
phone: selected.phone,
type: txType,
points: value,
description,
});
toast.success(txType === 'accrual' ? 'Puntos acumulados' : 'Puntos redimidos');
setModalOpen(false);
await load();
} catch (err) {
toast.error('Error en transacción');
console.error(err);
} finally {
setSubmitting(false);
}
};
return (
<Layout title="Monedero" subtitle="Puntos y recompensas">
<PageHeader title="Monedero" subtitle="Busca por teléfono y gestiona puntos" />
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por teléfono..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={5} className="h-14 w-full" />
) : wallets.length === 0 ? (
<EmptyState title="Sin monederos" subtitle="No se encontraron monederos." icon={<Wallet size={28} />} />
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Teléfono</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Equivalente MXN</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{wallets.map((w) => (
<tr key={w.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-medium text-theme-heading">{w.patient}</td>
<td className="p-3 text-sm text-theme-muted">{w.phone}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{w.points} pts</td>
<td className="p-3 text-sm text-theme-muted">${w.equivalent_mxn}</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openModal(w, 'accrual')}
title="Acumular"
>
<Plus size={16} className="text-theme-heading" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openModal(w, 'redemption')}
title="Redimir"
disabled={w.points <= 0}
>
<Minus size={16} className="text-rose-600" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{wallets.map((w) => (
<MobileCard
key={w.id}
title={w.patient}
subtitle={w.phone}
rows={[
{ label: 'Puntos', value: `${w.points} pts` },
{ label: 'Equivalente', value: `$${w.equivalent_mxn}` },
]}
actions={
<>
<Button variant="ghost" size="sm" onClick={() => openModal(w, 'accrual')}>
<Plus size={16} className="text-theme-heading" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openModal(w, 'redemption')} disabled={w.points <= 0}>
<Minus size={16} className="text-rose-600" />
</Button>
</>
}
/>
))}
</div>
{/* Paginación */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-theme-border">
<p className="text-sm text-theme-muted">
{total} monederos · página {page} de {totalPages || 1}
</p>
<div className="flex items-center gap-2">
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
className="border border-theme-border-strong rounded-lg px-2 py-1.5 text-sm"
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Anterior
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Siguiente
</Button>
</div>
</div>
</>
)}
</Card.Body>
</Card>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={txType === 'accrual' ? 'Acumular puntos' : 'Redimir puntos'}
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={submit} loading={submitting}>Guardar</Button>
</>
}
>
{selected && (
<div className="space-y-4">
<p className="text-sm text-theme-muted">
Paciente: <span className="font-medium text-theme-heading">{selected.patient}</span>
</p>
<p className="text-sm text-theme-muted">
Puntos disponibles: <span className="font-medium text-theme-heading">{selected.points} pts</span>
</p>
<Input
label="Puntos *"
type="number"
min={1}
value={points}
onChange={(e) => setPoints(e.target.value)}
/>
<TextArea
label="Descripción"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
)}
</Modal>
</Layout>
);
};
export default Monedero;

View File

@@ -0,0 +1,758 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import {
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
Download,
} from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
Select,
Modal,
Badge,
EmptyState,
MobileCard,
PageHeader,
Skeleton,
toast,
TextArea,
badgeForAppointmentState,
} from '../components/ui';
import { odooApi, type Patient, type Appointment, type Sale } from '../services/odoo';
import { downloadCsv } from '../lib/utils';
const genderOptions = [
{ value: '', label: 'No especificado' },
{ value: 'male', label: 'Masculino' },
{ value: 'female', label: 'Femenino' },
{ value: 'other', label: 'Otro' },
];
const bloodOptions = [
{ value: '', label: 'No especificado' },
{ value: 'a+', label: 'A+' },
{ value: 'a-', label: 'A-' },
{ value: 'b+', label: 'B+' },
{ value: 'b-', label: 'B-' },
{ value: 'ab+', label: 'AB+' },
{ value: 'ab-', label: 'AB-' },
{ value: 'o+', label: 'O+' },
{ value: 'o-', label: 'O-' },
];
const maritalOptions = [
{ value: '', label: 'No especificado' },
{ value: 'single', label: 'Soltero/a' },
{ value: 'married', label: 'Casado/a' },
{ value: 'divorced', label: 'Divorciado/a' },
{ value: 'widowed', label: 'Viudo/a' },
{ value: 'union', label: 'Unión libre' },
{ value: 'other', label: 'Otro' },
];
const formatGender = (gender?: string | false) => {
if (!gender) return 'No especificado';
const map: Record<string, string> = {
male: 'Masculino',
female: 'Femenino',
other: 'Otro',
};
return map[gender] || gender;
};
const formatMarital = (status?: string) => {
if (!status) return 'No especificado';
const opt = maritalOptions.find((o) => o.value === status);
return opt?.label || status;
};
const formatBlood = (bt?: string | false) => {
if (!bt || typeof bt !== 'string') return 'No especificado';
const opt = bloodOptions.find((o) => o.value === bt.toLowerCase());
return opt?.label || bt.toUpperCase();
};
const Pacientes: FC = () => {
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [selected, setSelected] = useState<Patient | null>(null);
const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[] } | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [tab, setTab] = useState<'info' | 'clinical' | 'appointments' | 'sales'>('info');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Patient | null>(null);
const emptyForm: PatientForm = {
name: '',
phone: '',
email: '',
birth_date: '',
gender: '',
blood_type: '',
birthplace: '',
occupation: '',
marital_status: '',
emergency_contact: '',
emergency_phone: '',
home_phone: '',
mobile: '',
address_notes: '',
referred_by: '',
patient_comments: '',
internal_notes: '',
allergies: '',
medical_history: '',
current_medication: '',
medical_notes: '',
surgeries_notes: '',
children_count: 0,
is_pregnant: false,
is_breastfeeding: false,
uses_contraceptives: false,
kidney_problems: false,
back_pain: false,
heart_disease: false,
respiratory_problems: false,
blood_pressure: false,
diabetes: false,
thyroid: false,
colitis: false,
constipation: false,
liver_problems: false,
surgeries: false,
varicose_veins: false,
migraine: false,
faints_with_needles: false,
};
const [form, setForm] = useState<PatientForm>(emptyForm);
const load = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getPatients({ search, page, page_size: pageSize });
if (res.status === 'success') {
setPatients(res.patients);
setTotal(res.total ?? 0);
setTotalPages(res.total_pages ?? 0);
}
} catch (err) {
setError('Error al cargar pacientes');
toast.error('Error al cargar pacientes');
console.error(err);
} finally {
setLoading(false);
}
}, [search, page, pageSize]);
useEffect(() => {
setPage(1);
}, [search]);
useEffect(() => {
load();
}, [load]);
const openHistory = async (patient: Patient) => {
setSelected(patient);
setTab('info');
setHistoryLoading(true);
try {
const res = await odooApi.getPatientHistory(patient.id);
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales });
} catch (err) {
toast.error('Error al cargar historial');
console.error(err);
} finally {
setHistoryLoading(false);
}
};
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setModalOpen(true);
};
const openEdit = (patient: Patient) => {
setEditing(patient);
setForm({
...emptyForm,
...patient,
birth_date: patient.birth_date || '',
gender: patient.gender || '',
blood_type: patient.blood_type || '',
marital_status: patient.marital_status || '',
children_count: patient.children_count ?? 0,
is_pregnant: patient.is_pregnant ?? false,
is_breastfeeding: patient.is_breastfeeding ?? false,
uses_contraceptives: patient.uses_contraceptives ?? false,
kidney_problems: patient.kidney_problems ?? false,
back_pain: patient.back_pain ?? false,
heart_disease: patient.heart_disease ?? false,
respiratory_problems: patient.respiratory_problems ?? false,
blood_pressure: patient.blood_pressure ?? false,
diabetes: patient.diabetes ?? false,
thyroid: patient.thyroid ?? false,
colitis: patient.colitis ?? false,
constipation: patient.constipation ?? false,
liver_problems: patient.liver_problems ?? false,
surgeries: patient.surgeries ?? false,
varicose_veins: patient.varicose_veins ?? false,
migraine: patient.migraine ?? false,
faints_with_needles: patient.faints_with_needles ?? false,
});
setModalOpen(true);
};
const updateForm = (patch: Partial<PatientForm>) => {
setForm((prev) => ({ ...prev, ...patch }));
};
const save = async () => {
if (!form.name || !form.phone) {
toast.error('Nombre y teléfono son obligatorios');
return;
}
try {
setSubmitting(true);
const payload: Partial<Patient> = {
...form,
birth_date: form.birth_date || null,
gender: form.gender || undefined,
blood_type: form.blood_type || undefined,
marital_status: form.marital_status || undefined,
};
if (editing) {
await odooApi.updatePatient(editing.id, payload);
toast.success('Paciente actualizado');
} else {
await odooApi.createPatient(payload);
toast.success('Paciente creado');
}
setModalOpen(false);
await load();
} catch (err) {
toast.error(editing ? 'Error al actualizar paciente' : 'Error al crear paciente');
console.error(err);
} finally {
setSubmitting(false);
}
};
const TableHeader = () => (
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Teléfono</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Edad</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Última visita</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Visitas</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
);
const InfoRow = ({ label, value, icon: Icon }: { label: string; value: React.ReactNode; icon?: typeof User }) => (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">{label}</p>
<p className="text-sm font-medium text-theme-heading flex items-start">
{Icon && <Icon size={14} className="mr-1.5 mt-0.5 text-theme-muted shrink-0" />}
<span className="break-words">{value || '-'}</span>
</p>
</div>
);
const ClinicalItem = ({ label, value }: { label: string; value?: boolean }) => (
<div className="flex items-center justify-between p-2.5 bg-theme-surface border border-theme-border rounded-lg">
<span className="text-sm text-theme-heading">{label}</span>
{value ? (
<CheckCircle2 size={16} className="text-theme-heading" />
) : (
<XCircle size={16} className="text-[#D6D3D1]" />
)}
</div>
);
const exportPatients = () => {
const rows: (string | number | boolean | null | undefined)[][] = [
['Expediente', 'Nombre', 'Teléfono', 'Email', 'Fecha nacimiento', 'Género', 'Última visita', 'Visitas', 'Puntos monedero', 'Total gastado', 'Fuente', 'VIP'],
];
patients.forEach((p) =>
rows.push([
p.patient_id, p.name, p.phone, p.email, p.birth_date, p.gender || '', p.last_visit,
p.total_visits, p.wallet_points, p.total_spent, p.source, p.is_vip ? 'Sí' : 'No',
])
);
downloadCsv(`pacientes-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
toast.success(`CSV descargado (${patients.length} pacientes de la vista actual)`);
};
return (
<Layout title="Pacientes" subtitle="Directorio de pacientes">
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
<Button variant="outline" onClick={exportPatients} disabled={patients.length === 0}>
<Download size={16} className="mr-2" />
Exportar CSV
</Button>
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo paciente
</Button>
</PageHeader>
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por nombre o teléfono..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : patients.length === 0 ? (
<EmptyState
title="Sin pacientes"
subtitle="No se encontraron pacientes."
actionLabel="Nuevo paciente"
onAction={openCreate}
/>
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<TableHeader />
<tbody className="divide-y">
{patients.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-medium text-theme-heading">
<button onClick={() => openHistory(p)} className="text-left hover:underline">
{p.name}
</button>
</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{p.phone}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{p.age ?? '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{p.last_visit || '-'}</td>
<td className="p-3 text-sm text-theme-muted">{p.total_visits}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{p.wallet_points} pts</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openHistory(p)} title="Ver detalle">
<User size={16} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(p)} title="Editar">
<Edit2 size={16} className="text-theme-muted" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{patients.map((p) => (
<MobileCard
key={p.id}
title={p.name}
subtitle={p.phone}
rows={[
{ label: 'Edad', value: p.age ?? '-' },
{ label: 'Última visita', value: p.last_visit || '-' },
{ label: 'Visitas', value: p.total_visits },
{ label: 'Puntos', value: `${p.wallet_points} pts` },
]}
actions={
<>
<Button variant="ghost" size="sm" onClick={() => openHistory(p)}>
<User size={16} />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
<Edit2 size={16} />
</Button>
</>
}
onClick={() => openHistory(p)}
/>
))}
</div>
{/* Paginación */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-theme-border">
<p className="text-sm text-theme-muted">
{total} pacientes · página {page} de {totalPages || 1}
</p>
<div className="flex items-center gap-2">
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
className="border border-theme-border-strong rounded-lg px-2 py-1.5 text-sm"
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Anterior
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Siguiente
</Button>
</div>
</div>
</>
)}
</Card.Body>
</Card>
{/* Modal crear/editar */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar paciente' : 'Nuevo paciente'}
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={save} loading={submitting}>{editing ? 'Actualizar' : 'Crear'}</Button>
</>
}
>
<div className="space-y-6 max-h-[70vh] overflow-y-auto pr-1">
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<User size={16} className="mr-2 text-theme-muted" /> Datos generales
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Nombre *" value={form.name} onChange={(e) => updateForm({ name: e.target.value })} />
<Input label="Teléfono *" value={form.phone} onChange={(e) => updateForm({ phone: e.target.value })} />
<Input label="Email" type="email" value={form.email} onChange={(e) => updateForm({ email: e.target.value })} />
<Input label="Celular" value={form.mobile} onChange={(e) => updateForm({ mobile: e.target.value })} />
<Input label="Teléfono casa" value={form.home_phone} onChange={(e) => updateForm({ home_phone: e.target.value })} />
<Input label="Fecha de nacimiento" type="date" value={form.birth_date} onChange={(e) => updateForm({ birth_date: e.target.value })} />
<Select label="Género" options={genderOptions} value={form.gender} onChange={(e) => updateForm({ gender: e.target.value })} />
<Select label="Tipo sanguíneo" options={bloodOptions} value={form.blood_type} onChange={(e) => updateForm({ blood_type: e.target.value })} />
</div>
</section>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<MapPin size={16} className="mr-2 text-theme-muted" /> Datos personales
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Lugar de nacimiento" value={form.birthplace} onChange={(e) => updateForm({ birthplace: e.target.value })} />
<Input label="Empleo / Ocupación" value={form.occupation} onChange={(e) => updateForm({ occupation: e.target.value })} />
<Select label="Estado civil" options={maritalOptions} value={form.marital_status} onChange={(e) => updateForm({ marital_status: e.target.value })} />
<Input label="Recomendado por" value={form.referred_by} onChange={(e) => updateForm({ referred_by: e.target.value })} />
<Input label="Contacto de emergencia" value={form.emergency_contact} onChange={(e) => updateForm({ emergency_contact: e.target.value })} />
<Input label="Teléfono de emergencia" value={form.emergency_phone} onChange={(e) => updateForm({ emergency_phone: e.target.value })} />
</div>
<div className="mt-4">
<TextArea label="Dirección completa" value={form.address_notes} onChange={(e) => updateForm({ address_notes: e.target.value })} />
</div>
</section>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<Baby size={16} className="mr-2 text-theme-muted" /> Antecedentes gineco-obstétricos
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={form.is_pregnant}
onChange={(e) => updateForm({ is_pregnant: e.target.checked })}
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
/>
<span className="text-sm text-theme-heading">¿Está embarazada?</span>
</label>
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={form.is_breastfeeding}
onChange={(e) => updateForm({ is_breastfeeding: e.target.checked })}
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
/>
<span className="text-sm text-theme-heading">¿Está lactando?</span>
</label>
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={form.uses_contraceptives}
onChange={(e) => updateForm({ uses_contraceptives: e.target.checked })}
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
/>
<span className="text-sm text-theme-heading">¿Usa anticonceptivos?</span>
</label>
<Input
label="Número de hijos"
type="number"
min={0}
value={String(form.children_count ?? 0)}
onChange={(e) => updateForm({ children_count: parseInt(e.target.value || '0', 10) })}
/>
</div>
</section>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<Stethoscope size={16} className="mr-2 text-theme-muted" /> Historia clínica
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{[
['kidney_problems', 'Problemas de riñón'],
['back_pain', 'Dolor de espalda'],
['heart_disease', 'Enfermedades cardíacas'],
['respiratory_problems', 'Problemas respiratorios'],
['blood_pressure', 'Presión arterial'],
['diabetes', 'Diabetes'],
['thyroid', 'Problemas de tiroides'],
['colitis', 'Colitis'],
['constipation', 'Estreñimiento'],
['liver_problems', 'Problemas de hígado'],
['surgeries', 'Cirugías'],
['varicose_veins', 'Varices'],
['migraine', 'Migraña'],
['faints_with_needles', 'Se desmaya con agujas'],
].map(([key, label]) => {
const checked = !!form[key as keyof PatientForm];
return (
<label key={key} className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={typeof checked === 'boolean' ? checked : false}
onChange={(e) => updateForm({ [key]: e.target.checked } as Partial<PatientForm>)}
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
/>
<span className="text-sm text-theme-heading">{label}</span>
</label>
);
})}
</div>
<div className="grid grid-cols-1 gap-4 mt-4">
<TextArea label="Alergias" value={form.allergies} onChange={(e) => updateForm({ allergies: e.target.value })} />
<TextArea label="Historial médico" value={form.medical_history} onChange={(e) => updateForm({ medical_history: e.target.value })} />
<TextArea label="Medicación actual" value={form.current_medication} onChange={(e) => updateForm({ current_medication: e.target.value })} />
<TextArea label="Detalle de cirugías" value={form.surgeries_notes} onChange={(e) => updateForm({ surgeries_notes: e.target.value })} />
<TextArea label="Notas médicas adicionales" value={form.medical_notes} onChange={(e) => updateForm({ medical_notes: e.target.value })} />
<TextArea label="Comentarios del paciente" value={form.patient_comments} onChange={(e) => updateForm({ patient_comments: e.target.value })} />
<TextArea label="Notas internas (solo equipo, no visible para el paciente)" value={form.internal_notes} onChange={(e) => updateForm({ internal_notes: e.target.value })} />
</div>
</section>
</div>
</Modal>
{/* Modal detalle */}
<Modal
isOpen={!!selected}
onClose={() => setSelected(null)}
title={selected?.name || 'Detalle del paciente'}
maxWidth="2xl"
>
<div className="space-y-4">
<div className="flex gap-2 border-b border-theme-border pb-2 overflow-x-auto">
{([
{ key: 'info', label: 'Información general' },
{ key: 'clinical', label: 'Historia clínica' },
{ key: 'appointments', label: 'Citas' },
{ key: 'sales', label: 'Ventas' },
] as const).map(({ key, label }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg whitespace-nowrap ${
tab === key ? 'bg-[#1A1A1A] text-white' : 'text-theme-muted hover:bg-theme-bg'
}`}
>
{label}
</button>
))}
</div>
{historyLoading ? (
<Skeleton count={4} className="h-16 w-full" />
) : tab === 'info' ? (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<InfoRow label="Folio" value={selected?.patient_id} icon={FileText} />
<InfoRow label="Teléfono" value={selected?.phone} icon={Phone} />
<InfoRow label="Celular" value={selected?.mobile} icon={Phone} />
<InfoRow label="Email" value={selected?.email} icon={Mail} />
<InfoRow label="Teléfono casa" value={selected?.home_phone} icon={Phone} />
<InfoRow label="Fecha de nacimiento" value={selected?.birth_date} icon={Calendar} />
<InfoRow label="Edad" value={selected?.age} icon={Calendar} />
<InfoRow label="Género" value={formatGender(selected?.gender)} icon={User} />
<InfoRow label="Tipo sanguíneo" value={formatBlood(selected?.blood_type)} icon={Droplet} />
<InfoRow label="Lugar de nacimiento" value={selected?.birthplace} icon={MapPin} />
<InfoRow label="Empleo / Ocupación" value={selected?.occupation} icon={Briefcase} />
<InfoRow label="Estado civil" value={formatMarital(selected?.marital_status)} icon={Users} />
<InfoRow label="Contacto de emergencia" value={selected?.emergency_contact} icon={AlertCircle} />
<InfoRow label="Tel. emergencia" value={selected?.emergency_phone} icon={Phone} />
<InfoRow label="Recomendado por" value={selected?.referred_by} icon={Users} />
<InfoRow label="Puntos" value={`${selected?.wallet_points ?? 0} pts`} icon={Heart} />
</div>
{selected?.address_notes && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Dirección</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.address_notes}</p>
</div>
)}
{selected?.patient_comments && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Comentarios</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.patient_comments}</p>
</div>
)}
{selected?.internal_notes && (
<div className="p-3 bg-amber-50 border border-theme-bg rounded-xl">
<p className="text-xs text-theme-warning-text mb-1">🔒 Notas internas (solo equipo)</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.internal_notes}</p>
</div>
)}
</div>
) : tab === 'clinical' ? (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<ClinicalItem label="Embarazo" value={selected?.is_pregnant} />
<ClinicalItem label="Lactancia" value={selected?.is_breastfeeding} />
<ClinicalItem label="Anticonceptivos" value={selected?.uses_contraceptives} />
<ClinicalItem label="Problemas de riñón" value={selected?.kidney_problems} />
<ClinicalItem label="Dolor de espalda" value={selected?.back_pain} />
<ClinicalItem label="Enfermedades cardíacas" value={selected?.heart_disease} />
<ClinicalItem label="Problemas respiratorios" value={selected?.respiratory_problems} />
<ClinicalItem label="Presión arterial" value={selected?.blood_pressure} />
<ClinicalItem label="Diabetes" value={selected?.diabetes} />
<ClinicalItem label="Problemas de tiroides" value={selected?.thyroid} />
<ClinicalItem label="Colitis" value={selected?.colitis} />
<ClinicalItem label="Estreñimiento" value={selected?.constipation} />
<ClinicalItem label="Problemas de hígado" value={selected?.liver_problems} />
<ClinicalItem label="Cirugías" value={selected?.surgeries} />
<ClinicalItem label="Varices" value={selected?.varicose_veins} />
<ClinicalItem label="Migraña" value={selected?.migraine} />
<ClinicalItem label="Se desmaya con agujas" value={selected?.faints_with_needles} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoRow label="Número de hijos" value={selected?.children_count} />
</div>
{[
{ label: 'Alergias', value: selected?.allergies },
{ label: 'Historial médico', value: selected?.medical_history },
{ label: 'Medicación actual', value: selected?.current_medication },
{ label: 'Detalle de cirugías', value: selected?.surgeries_notes },
{ label: 'Notas médicas adicionales', value: selected?.medical_notes },
].map(({ label, value }) =>
value ? (
<div key={label} className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">{label}</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{value}</p>
</div>
) : null
)}
</div>
) : tab === 'appointments' ? (
history && history.appointments.length > 0 ? (
<div className="space-y-2">
{history.appointments.map((a) => (
<div key={a.id} className="p-3 border border-theme-border rounded-xl text-sm">
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">
{a.date} {a.time}
</p>
<Badge variant={badgeForAppointmentState(a.state)}>{a.state}</Badge>
</div>
<p className="text-theme-muted mt-1">{a.service}</p>
</div>
))}
</div>
) : (
<EmptyState title="Sin citas" subtitle="Este paciente no tiene citas registradas." icon={<Activity size={28} />} />
)
) : (
history && history.sales.length > 0 ? (
<div className="space-y-2">
{history.sales.map((s) => (
<div key={s.id} className="p-3 border border-theme-border rounded-xl text-sm">
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">{s.name}</p>
<p className="font-semibold text-theme-heading">${s.total}</p>
</div>
<p className="text-theme-muted mt-1">{s.date} {s.state}</p>
</div>
))}
</div>
) : (
<EmptyState title="Sin ventas" subtitle="Este paciente no tiene ventas registradas." icon={<ShoppingBag size={28} />} />
)
)}
</div>
</Modal>
</Layout>
);
};
interface PatientForm {
name: string;
phone: string;
email: string;
birth_date: string;
gender: string;
blood_type: string;
birthplace: string;
occupation: string;
marital_status: string;
emergency_contact: string;
emergency_phone: string;
home_phone: string;
mobile: string;
address_notes: string;
referred_by: string;
patient_comments: string;
internal_notes: string;
allergies: string;
medical_history: string;
current_medication: string;
medical_notes: string;
surgeries_notes: string;
children_count: number;
is_pregnant: boolean;
is_breastfeeding: boolean;
uses_contraceptives: boolean;
kidney_problems: boolean;
back_pain: boolean;
heart_disease: boolean;
respiratory_problems: boolean;
blood_pressure: boolean;
diabetes: boolean;
thyroid: boolean;
colitis: boolean;
constipation: boolean;
liver_problems: boolean;
surgeries: boolean;
varicose_veins: boolean;
migraine: boolean;
faints_with_needles: boolean;
}
export default Pacientes;

View File

@@ -0,0 +1,223 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Plus, CheckCircle, CreditCard } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
Select,
Modal,
Badge,
EmptyState,
PageHeader,
Skeleton,
MobileCard,
toast,
badgeForPaymentState,
} from '../components/ui';
import { odooApi, type Payment, type Patient } from '../services/odoo';
const methods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago', 'Stripe'];
const methodOptions = methods.map((m) => ({ value: m, label: m }));
const Pagos: FC = () => {
const [payments, setPayments] = useState<Payment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [patient, setPatient] = useState('');
const [amount, setAmount] = useState('');
const [method, setMethod] = useState('Efectivo');
const load = async () => {
try {
setLoading(true);
setError(null);
const [paymentsRes, patientsRes] = await Promise.all([
odooApi.getPayments(),
odooApi.getPatients(),
]);
if (paymentsRes.status === 'success') setPayments(paymentsRes.payments);
if (patientsRes.status === 'success') setPatients(patientsRes.patients);
} catch (err) {
setError('Error al cargar pagos');
toast.error('Error al cargar pagos');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const create = async () => {
const value = parseFloat(amount);
if (!patient || Number.isNaN(value) || value <= 0) {
toast.error('Selecciona paciente e ingresa un monto válido');
return;
}
try {
setSubmitting(true);
await odooApi.createPayment({ patient, amount: value, payment_method: method });
toast.success('Pago creado');
setModalOpen(false);
setPatient('');
setAmount('');
setMethod('Efectivo');
await load();
} catch (err) {
toast.error('Error al crear pago');
console.error(err);
} finally {
setSubmitting(false);
}
};
const confirm = async (id: number) => {
try {
await odooApi.confirmPayment(id);
toast.success('Pago confirmado');
await load();
} catch (err) {
toast.error('Error al confirmar pago');
console.error(err);
}
};
const patientOptions = [
{ value: '', label: 'Seleccionar paciente...' },
...patients.map((p) => ({ value: p.name, label: `${p.name} (${p.phone})` })),
];
return (
<Layout title="Pagos" subtitle="Transacciones">
<PageHeader title="Pagos" subtitle="Crea y confirma pagos">
<Button onClick={() => setModalOpen(true)}>
<Plus size={16} className="mr-2" />
Nuevo pago
</Button>
</PageHeader>
<Card>
<Card.Body>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : payments.length === 0 ? (
<EmptyState
title="Sin pagos"
subtitle="No hay pagos registrados."
actionLabel="Nuevo pago"
onAction={() => setModalOpen(true)}
icon={<CreditCard size={28} />}
/>
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Monto</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Método</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{payments.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-muted">{p.name}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{p.patient}</td>
<td className="p-3 text-sm font-semibold text-theme-heading">${p.amount}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{p.payment_method}</td>
<td className="p-3">
<Badge variant={badgeForPaymentState(p.state)}>{p.state}</Badge>
</td>
<td className="p-3">
{p.state !== 'confirmed' && p.state !== 'posted' && (
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)} title="Confirmar">
<CheckCircle size={16} className="text-theme-heading" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{payments.map((p) => (
<MobileCard
key={p.id}
title={p.patient}
subtitle={p.name}
rows={[
{ label: 'Monto', value: `$${p.amount}` },
{ label: 'Método', value: p.payment_method },
{ label: 'Estado', value: <Badge variant={badgeForPaymentState(p.state)}>{p.state}</Badge> },
]}
actions={
p.state !== 'confirmed' && p.state !== 'posted' && (
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)}>
<CheckCircle size={16} className="text-theme-heading" />
</Button>
)
}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title="Nuevo pago"
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={create} loading={submitting}>Crear pago</Button>
</>
}
>
<div className="space-y-4">
<Select
label="Paciente *"
options={patientOptions}
value={patient}
onChange={(e) => setPatient(e.target.value)}
/>
<Input
label="Monto *"
type="number"
min={0.01}
step={0.01}
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<Select
label="Método de pago"
options={methodOptions}
value={method}
onChange={(e) => setMethod(e.target.value)}
/>
</div>
</Modal>
</Layout>
);
};
export default Pagos;

View File

@@ -0,0 +1,129 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Package, Search } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Input, Badge, EmptyState, PageHeader, Skeleton, MobileCard, toast } from '../components/ui';
import { odooApi, type Product } from '../services/odoo';
const translateType = (type: string) => {
const map: Record<string, string> = {
product: 'Producto almacenable',
service: 'Servicio',
consu: 'Consumible',
};
return map[type] || type;
};
const Productos: FC = () => {
const [products, setProducts] = useState<Product[]>([]);
const [filtered, setFiltered] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getProducts();
if (res.status === 'success') {
setProducts(res.products);
setFiltered(res.products);
}
} catch (err) {
setError('Error al cargar productos');
toast.error('Error al cargar productos');
console.error(err);
} finally {
setLoading(false);
}
};
load();
}, []);
useEffect(() => {
const term = search.toLowerCase();
setFiltered(
products.filter(
(p) =>
p.name.toLowerCase().includes(term) ||
(p.default_code && p.default_code.toLowerCase().includes(term)) ||
p.type.toLowerCase().includes(term)
)
);
}, [search, products]);
return (
<Layout title="Productos" subtitle="Inventario">
<PageHeader title="Productos" subtitle="Catálogo de productos" />
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por nombre, código o tipo..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : filtered.length === 0 ? (
<EmptyState title="Sin productos" subtitle="No se encontraron productos." icon={<Package size={28} />} />
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Tipo</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Precio</th>
</tr>
</thead>
<tbody className="divide-y">
{filtered.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-muted">{p.default_code || '-'}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{p.name}</td>
<td className="p-3">
<Badge variant="default">{translateType(p.type)}</Badge>
</td>
<td className="p-3 text-sm font-semibold text-theme-heading">${p.list_price}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{filtered.map((p) => (
<MobileCard
key={p.id}
title={p.name}
subtitle={<Badge variant="default">{translateType(p.type)}</Badge>}
rows={[
{ label: 'Referencia', value: p.default_code || '-' },
{ label: 'Precio', value: `$${p.list_price}` },
]}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
</Layout>
);
};
export default Productos;

View File

@@ -0,0 +1,302 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Download, BarChart3 } from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import Layout from '../components/Layout';
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo';
import { downloadCsv } from '../lib/utils';
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
interface SalesReport {
total_sales: number;
total_paid: number;
total_due: number;
count: number;
}
interface AppointmentsReport {
total: number;
by_state: Record<string, number>;
}
interface CashReport {
total: number;
by_method: Record<string, number>;
}
const Reportes: FC = () => {
const [start, setStart] = useState(() => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
});
const [end, setEnd] = useState(() => new Date().toISOString().split('T')[0]);
const [today, setToday] = useState(() => new Date().toISOString().split('T')[0]);
const [salesReport, setSalesReport] = useState<SalesReport | null>(null);
const [appointmentsReport, setAppointmentsReport] = useState<AppointmentsReport | null>(null);
const [cashReport, setCashReport] = useState<CashReport | null>(null);
const [commissions, setCommissions] = useState<CommissionRow[]>([]);
const [commTotals, setCommTotals] = useState<{ base: number; commission: number }>({ base: 0, commission: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
try {
setLoading(true);
setError(null);
const [salesRes, aptRes, cashRes, commRes] = await Promise.all([
odooApi.getSalesReport(start, end),
odooApi.getAppointmentsReport(start, end),
odooApi.getCashReport(today),
odooApi.getCommissions(start, end),
]);
if (salesRes.status === 'success')
setSalesReport({
total_sales: salesRes.total_sales,
total_paid: salesRes.total_paid,
total_due: salesRes.total_due,
count: salesRes.count,
});
if (aptRes.status === 'success')
setAppointmentsReport({ total: aptRes.total, by_state: aptRes.by_state });
if (cashRes.status === 'success')
setCashReport({ total: cashRes.total, by_method: cashRes.by_method });
if (commRes.status === 'success') {
setCommissions(commRes.commissions || []);
setCommTotals({ base: commRes.total_base || 0, commission: commRes.total_commission || 0 });
}
} catch (err) {
setError('Error al cargar reportes');
toast.error('Error al cargar reportes');
console.error(err);
} finally {
setLoading(false);
}
};
load();
}, [start, end, today]);
const aptData = appointmentsReport
? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value }))
: [];
const cashData = cashReport
? Object.entries(cashReport.by_method).map(([name, value]) => ({ name, value }))
: [];
const exportCSV = () => {
const rows: (string | number)[][] = [];
rows.push(['Reporte SKEEN']);
rows.push(['Rango', `${start} a ${end}`]);
rows.push([]);
rows.push(['Ventas']);
rows.push(['Total', 'Pagado', 'Por cobrar', 'Cantidad']);
if (salesReport) {
rows.push([
salesReport.total_sales,
salesReport.total_paid,
salesReport.total_due,
salesReport.count,
]);
}
rows.push([]);
rows.push(['Citas por estado']);
rows.push(['Estado', 'Cantidad']);
aptData.forEach((row) => rows.push([row.name, row.value]));
rows.push([]);
rows.push(['Efectivo por método']);
rows.push(['Método', 'Monto']);
cashData.forEach((row) => rows.push([row.name, row.value]));
rows.push([]);
rows.push(['Comisiones por médico']);
rows.push(['Médico', 'Puesto', '% Comisión', 'Artículos', 'Ventas', 'Base recetada', 'Comisión']);
commissions.forEach((c) =>
rows.push([c.doctor, c.job_title || '', c.commission_pct, c.items, c.sales, c.base, c.commission])
);
rows.push(['', '', '', '', 'Totales', commTotals.base, commTotals.commission]);
downloadCsv(`reporte-skeen-${start}_${end}.csv`, rows);
toast.success('CSV descargado');
};
return (
<Layout title="Reportes" subtitle="Análisis del mes y día">
<PageHeader title="Reportes" subtitle="Análisis del mes y día">
<Button variant="outline" onClick={exportCSV} disabled={loading || !salesReport}>
<Download size={16} className="mr-2" />
Exportar CSV
</Button>
</PageHeader>
<Card className="mb-6">
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
<Input
label="Inicio"
type="date"
value={start}
onChange={(e) => setStart(e.target.value)}
className="sm:max-w-[180px]"
/>
<Input
label="Fin"
type="date"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="sm:max-w-[180px]"
/>
<Input
label="Día efectivo"
type="date"
value={today}
onChange={(e) => setToday(e.target.value)}
className="sm:max-w-[180px]"
/>
</div>
{error && <p className="text-rose-600 text-sm mt-4">{error}</p>}
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : salesReport ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Ventas</p>
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_sales}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Pagado</p>
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_paid}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Por cobrar</p>
<p className="text-xl font-heading font-semibold text-theme-heading">${salesReport.total_due}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Cantidad</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{salesReport.count}</p>
</Card>
</div>
) : null}
</Card.Body>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Citas por estado</h3>
{loading ? (
<Skeleton className="h-[250px] w-full" />
) : aptData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay citas en el periodo." icon={<BarChart3 size={28} />} />
) : (
<ResponsiveContainer width="100%" height={250}>
<BarChart data={aptData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
<Bar dataKey="value" fill="#57534e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Efectivo por método</h3>
{loading ? (
<Skeleton className="h-[250px] w-full" />
) : cashData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay pagos registrados para el día." icon={<BarChart3 size={28} />} />
) : (
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={cashData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
{cashData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
</PieChart>
</ResponsiveContainer>
)}
</Card.Body>
</Card>
</div>
<Card className="mt-4 sm:mt-6">
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<div>
<h3 className="font-heading text-xl text-theme-heading">Comisiones por médico</h3>
<p className="text-xs text-theme-muted">Comisión = % del médico sobre la venta de artículos recetados por él.</p>
</div>
<div className="text-sm text-theme-muted">
Base recetada: <span className="font-semibold text-theme-heading">${commTotals.base.toLocaleString('es-MX')}</span>
{' · '}Comisión total: <span className="font-semibold text-theme-heading">${commTotals.commission.toLocaleString('es-MX')}</span>
</div>
</div>
{loading ? (
<Skeleton className="h-24 w-full" />
) : commissions.length === 0 ? (
<p className="text-sm text-theme-muted">No hay ventas de artículos recetados en el periodo. Marca las líneas como Artículo recetado al crear la venta y asigna el % en Médicos.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Médico</th>
<th className="text-right px-4 py-3">% Comisión</th>
<th className="text-right px-4 py-3">Artículos</th>
<th className="text-right px-4 py-3">Ventas</th>
<th className="text-right px-4 py-3">Base recetada</th>
<th className="text-right px-4 py-3">Comisión</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{commissions.map((c) => (
<tr key={c.doctor_id} className="hover:bg-theme-bg">
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{c.doctor}</p>
<p className="text-xs text-theme-muted">{c.job_title || '—'}</p>
</td>
<td className="px-4 py-3 text-right text-theme-heading">{c.commission_pct}%</td>
<td className="px-4 py-3 text-right text-theme-muted">{c.items}</td>
<td className="px-4 py-3 text-right text-theme-muted">{c.sales}</td>
<td className="px-4 py-3 text-right text-theme-heading">${c.base.toLocaleString('es-MX')}</td>
<td className="px-4 py-3 text-right font-semibold text-theme-heading">${c.commission.toLocaleString('es-MX')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</Layout>
);
};
export default Reportes;

View File

@@ -0,0 +1,365 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Star, Edit2, Check, X, Plus, Clock, Tag, Package } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
TextArea,
Modal,
EmptyState,
PageHeader,
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Service } from '../services/odoo';
const Servicios: FC = () => {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<Service | null>(null);
const [editPrice, setEditPrice] = useState('');
const [editPackagePrice, setEditPackagePrice] = useState('');
const [editPackageSessions, setEditPackageSessions] = useState('');
const [editPackageNotes, setEditPackageNotes] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({
name: '',
code: '',
category: '',
price: '',
package_price: '',
package_sessions: '',
package_notes: '',
duration_min: '',
description: '',
color: '#78716c',
service_group: '',
});
const load = async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getServices();
if (res.status === 'success') setServices(res.services);
} catch (err) {
setError('Error al cargar servicios');
toast.error('Error al cargar servicios');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const toggleFavorite = async (s: Service) => {
try {
await odooApi.updateService(s.id, { is_favorite: !s.is_favorite });
toast.success('Favorito actualizado');
await load();
} catch (err) {
toast.error('Error al actualizar favorito');
console.error(err);
}
};
const startEdit = (s: Service) => {
setEditing(s);
setEditPrice(String(s.price));
setEditPackagePrice(String(s.package_price ?? ''));
setEditPackageSessions(String(s.package_sessions ?? ''));
setEditPackageNotes(s.package_notes || '');
};
const savePrice = async () => {
if (!editing) return;
const price = parseFloat(editPrice);
const packagePrice = editPackagePrice === '' ? 0 : parseFloat(editPackagePrice);
const packageSessions = editPackageSessions === '' ? 0 : parseInt(editPackageSessions, 10);
if (Number.isNaN(price) || price < 0) {
toast.error('Precio inválido');
return;
}
try {
await odooApi.updateService(editing.id, {
price,
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
package_notes: editPackageNotes,
});
toast.success('Servicio actualizado');
setEditing(null);
await load();
} catch (err) {
toast.error('Error al actualizar servicio');
console.error(err);
}
};
const create = async () => {
const price = parseFloat(form.price);
const duration = parseInt(form.duration_min, 10);
const packagePrice = form.package_price === '' ? 0 : parseFloat(form.package_price);
const packageSessions = form.package_sessions === '' ? 0 : parseInt(form.package_sessions, 10);
if (!form.name || Number.isNaN(price) || price < 0) {
toast.error('Nombre y precio válidos son obligatorios');
return;
}
try {
setSubmitting(true);
await odooApi.createService({
name: form.name,
code: form.code,
category: form.category,
price,
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
package_notes: form.package_notes,
duration_min: Number.isNaN(duration) ? 0 : duration,
description: form.description,
color: form.color,
service_group: form.service_group,
});
toast.success('Servicio creado');
setCreateOpen(false);
setForm({
name: '',
code: '',
category: '',
price: '',
package_price: '',
package_sessions: '',
package_notes: '',
duration_min: '',
description: '',
color: '#78716c',
service_group: '',
});
await load();
} catch (err) {
toast.error('Error al crear servicio');
console.error(err);
} finally {
setSubmitting(false);
}
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
return (
<Layout title="Servicios" subtitle="Catálogo de tratamientos">
<PageHeader title="Servicios" subtitle="Gestiona precios, paquetes y favoritos">
<Button onClick={() => setCreateOpen(true)}>
<Plus size={16} className="mr-2" />
Nuevo servicio
</Button>
</PageHeader>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-48 w-full" />)}
</div>
) : services.length === 0 ? (
<EmptyState
title="Sin servicios"
subtitle="No hay servicios registrados."
actionLabel="Nuevo servicio"
onAction={() => setCreateOpen(true)}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((s) => (
<Card key={s.id} className="hover:shadow-md transition">
<Card.Body>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 min-w-0">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: s.color || '#78716c' }}
/>
<h4 className="font-medium text-theme-heading pr-2 truncate">{s.name}</h4>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleFavorite(s)}
title={s.is_favorite ? 'Quitar favorito' : 'Marcar favorito'}
>
<Star
size={18}
className={s.is_favorite ? 'text-amber-500' : 'text-theme-muted'}
fill={s.is_favorite ? 'currentColor' : 'none'}
/>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
<Tag size={12} className="mr-1" />
{s.category || 'Sin categoría'}
</span>
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
<Clock size={12} className="mr-1" />
{s.duration_min} min
</span>
</div>
{editing?.id === s.id ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Input
label="Precio"
type="number"
value={editPrice}
onChange={(e) => setEditPrice(e.target.value)}
/>
<Input
label="Precio paquete"
type="number"
value={editPackagePrice}
onChange={(e) => setEditPackagePrice(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Sesiones"
type="number"
value={editPackageSessions}
onChange={(e) => setEditPackageSessions(e.target.value)}
/>
<Input
label="Notas paquete"
value={editPackageNotes}
onChange={(e) => setEditPackageNotes(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={savePrice} title="Guardar">
<Check size={16} className="text-theme-heading" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(null)} title="Cancelar">
<X size={16} className="text-theme-muted" />
</Button>
</div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-theme-muted">Precio sesión</p>
<p className="text-lg font-heading font-semibold text-theme-heading">{formatCurrency(s.price)}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => startEdit(s)} title="Editar">
<Edit2 size={16} className="text-theme-muted" />
</Button>
</div>
{(s.package_price ?? 0) > 0 && (s.package_sessions ?? 0) > 0 && (
<div className="flex items-start gap-2 rounded-xl p-2.5" style={{ backgroundColor: 'var(--success)' }}>
<Package size={14} className="shrink-0 mt-0.5" style={{ color: 'var(--success-text)' }} />
<div>
<p className="text-xs font-medium" style={{ color: 'var(--success-text)' }}>
Paquete {s.package_sessions} sesiones: {formatCurrency(s.package_price ?? 0)}
</p>
{s.package_notes && <p className="text-[10px]" style={{ color: 'var(--success-text)' }}>{s.package_notes}</p>}
</div>
</div>
)}
</div>
)}
</Card.Body>
</Card>
))}
</div>
)}
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
title="Nuevo servicio"
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={create} loading={submitting}>Crear servicio</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Nombre *"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Código"
value={form.code}
onChange={(e) => setForm({ ...form, code: e.target.value })}
/>
<Input
label="Categoría"
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Precio sesión *"
type="number"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
/>
<Input
label="Duración (min)"
type="number"
value={form.duration_min}
onChange={(e) => setForm({ ...form, duration_min: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Precio paquete"
type="number"
value={form.package_price}
onChange={(e) => setForm({ ...form, package_price: e.target.value })}
/>
<Input
label="Sesiones en paquete"
type="number"
value={form.package_sessions}
onChange={(e) => setForm({ ...form, package_sessions: e.target.value })}
/>
</div>
<Input
label="Notas del paquete"
value={form.package_notes}
onChange={(e) => setForm({ ...form, package_notes: e.target.value })}
/>
<Input
label="Color"
type="color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
<TextArea
label="Descripción"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
</Modal>
</Layout>
);
};
export default Servicios;

View File

@@ -0,0 +1,265 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Pencil, KeyRound } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
Select,
Modal,
Badge,
EmptyState,
PageHeader,
Skeleton,
MobileCard,
toast,
} from '../components/ui';
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
import { ROLE_LABELS } from '../lib/auth';
const roleOptions: { value: FrontendRole; label: string }[] = (
['admin', 'recepcion', 'medico', 'lectura'] as FrontendRole[]
).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
const roleBadge = (role: FrontendRole) => {
switch (role) {
case 'admin':
return 'danger';
case 'recepcion':
return 'info';
case 'medico':
return 'success';
default:
return 'default';
}
};
const Usuarios: FC = () => {
const [users, setUsers] = useState<FrontendUser[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FrontendUser | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({ login: '', name: '', password: '', role: 'recepcion' as FrontendRole, active: true });
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.listFrontendUsers();
if (res.status === 'success') setUsers(res.users);
} catch (err) {
toast.error('Error al cargar usuarios');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setForm({ login: '', name: '', password: '', role: 'recepcion', active: true });
setModalOpen(true);
};
const openEdit = (u: FrontendUser) => {
setEditing(u);
setForm({ login: u.login, name: u.name, password: '', role: u.role, active: u.active !== false });
setModalOpen(true);
};
const submit = async () => {
if (!form.name.trim()) {
toast.error('El nombre es obligatorio');
return;
}
if (!editing && !form.login.trim()) {
toast.error('El usuario es obligatorio');
return;
}
if (!editing && form.password.length < 8) {
toast.error('La contraseña debe tener al menos 8 caracteres');
return;
}
if (editing && form.password && form.password.length < 8) {
toast.error('La nueva contraseña debe tener al menos 8 caracteres');
return;
}
try {
setSubmitting(true);
if (editing) {
await odooApi.updateFrontendUser(editing.id, {
name: form.name.trim(),
role: form.role,
active: form.active,
...(form.password ? { password: form.password } : {}),
});
toast.success('Usuario actualizado');
} else {
await odooApi.createFrontendUser({
login: form.login.trim(),
name: form.name.trim(),
password: form.password,
role: form.role,
});
toast.success('Usuario creado');
}
setModalOpen(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar usuario');
console.error(err);
} finally {
setSubmitting(false);
}
};
const toggleActive = async (u: FrontendUser) => {
try {
await odooApi.updateFrontendUser(u.id, { active: !(u.active !== false) });
await load();
} catch (err) {
toast.error('Error al cambiar estado');
console.error(err);
}
};
return (
<Layout title="Usuarios" subtitle="Cuentas y roles del frontend">
<PageHeader title="Usuarios" subtitle="Gestiona quién puede entrar al sistema y su rol">
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo usuario
</Button>
</PageHeader>
<Card>
<Card.Body>
{loading ? (
<Skeleton count={4} className="h-12 w-full" />
) : users.length === 0 ? (
<EmptyState title="Sin usuarios" subtitle="Crea el primer usuario." actionLabel="Nuevo usuario" onAction={openCreate} />
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Usuario</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Rol</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Último acceso</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{users.map((u) => (
<tr key={u.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-mono text-theme-heading">{u.login}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{u.name}</td>
<td className="p-3"><Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge></td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{u.last_login || '—'}</td>
<td className="p-3">
<Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(u)} title="Editar">
<Pencil size={16} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)} title={u.active !== false ? 'Desactivar' : 'Activar'}>
<KeyRound size={16} className={u.active !== false ? 'text-amber-600' : 'text-theme-heading'} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{users.map((u) => (
<MobileCard
key={u.id}
title={u.name}
subtitle={u.login}
rows={[
{ label: 'Rol', value: <Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge> },
{ label: 'Estado', value: <Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge> },
]}
actions={
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={16} className="text-theme-muted" /></Button>
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)}><KeyRound size={16} className="text-amber-600" /></Button>
</div>
}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar usuario' : 'Nuevo usuario'}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Usuario (login)"
value={form.login}
onChange={(e) => setForm({ ...form, login: e.target.value })}
disabled={!!editing}
placeholder="ej. recepcion1"
/>
<Input
label="Nombre"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Nombre completo"
/>
<Select
label="Rol"
options={roleOptions}
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as FrontendRole })}
/>
<Input
label={editing ? 'Nueva contraseña (dejar vacío para no cambiar)' : 'Contraseña (mín. 8)'}
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
minLength={8}
/>
{editing && (
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={form.active}
onChange={(e) => setForm({ ...form, active: e.target.checked })}
className="rounded border-theme-border-strong"
/>
Usuario activo
</label>
)}
</div>
</Modal>
</Layout>
);
};
export default Usuarios;

View File

@@ -0,0 +1,631 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Select,
Input,
TextArea,
Modal,
Badge,
EmptyState,
PageHeader,
Skeleton,
MobileCard,
toast,
badgeForSaleState,
} from '../components/ui';
import { odooApi, type Sale, type SaleLine, type Service, type Patient, type Doctor } from '../services/odoo';
import { downloadCsv } from '../lib/utils';
interface LineForm {
service_id: string;
description: string;
quantity: string;
price_unit: string;
is_prescribed: boolean;
prescribed_by_id: string;
}
const emptyLine = (): LineForm => ({
service_id: '', description: '', quantity: '1', price_unit: '',
is_prescribed: false, prescribed_by_id: '',
});
const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
const Ventas: FC = () => {
const [sales, setSales] = useState<Sale[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [payOpen, setPayOpen] = useState<Sale | null>(null);
const [patientId, setPatientId] = useState('');
const [lines, setLines] = useState<LineForm[]>([
emptyLine(),
]);
const [submitting, setSubmitting] = useState(false);
const [paying, setPaying] = useState(false);
const [payMethod, setPayMethod] = useState('Efectivo');
const [payAmount, setPayAmount] = useState('');
const [refundOpen, setRefundOpen] = useState<Sale | null>(null);
const [refunding, setRefunding] = useState(false);
const [refundAmount, setRefundAmount] = useState('');
const [refundReason, setRefundReason] = useState('');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [patientSearch, setPatientSearch] = useState('');
const loadSales = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getSales({ search, page, page_size: pageSize });
if (res.status === 'success') {
setSales(res.sales);
setTotal(res.total ?? 0);
setTotalPages(res.total_pages ?? 0);
}
} catch (err) {
setError('Error al cargar ventas');
toast.error('Error al cargar ventas');
console.error(err);
} finally {
setLoading(false);
}
}, [search, page, pageSize]);
const loadRefs = useCallback(async () => {
try {
const servicesRes = await odooApi.getServices();
if (servicesRes.status === 'success') setServices(servicesRes.services);
} catch (err) {
console.error(err);
}
try {
const doctorsRes = await odooApi.getDoctors();
if (doctorsRes.status === 'success') setDoctors(doctorsRes.doctors);
} catch (err) {
console.error(err);
}
}, []);
useEffect(() => {
loadRefs();
}, [loadRefs]);
useEffect(() => {
loadSales();
}, [loadSales]);
useEffect(() => {
setPage(1);
}, [search]);
// Cargar pacientes para el selector (búsqueda server-side)
useEffect(() => {
if (!createOpen) return;
const t = setTimeout(async () => {
try {
const res = await odooApi.getPatients({ search: patientSearch, page_size: 50 });
if (res.status === 'success') setPatients(res.patients);
} catch (err) {
console.error(err);
}
}, 250);
return () => clearTimeout(t);
}, [createOpen, patientSearch]);
const addLine = () =>
setLines([...lines, emptyLine()]);
const removeLine = (index: number) => setLines(lines.filter((_, i) => i !== index));
const updateLine = (index: number, field: keyof LineForm, value: string | boolean) => {
const next = [...lines];
next[index] = { ...next[index], [field]: value };
setLines(next);
};
const create = async () => {
if (!patientId) {
toast.error('Selecciona un paciente');
return;
}
const parsedLines: Omit<SaleLine, 'subtotal'>[] = lines
.filter((l) => l.service_id)
.map((l): Omit<SaleLine, 'subtotal'> | null => {
const qty = parseFloat(l.quantity);
const price = parseFloat(l.price_unit);
if (Number.isNaN(qty) || qty <= 0 || Number.isNaN(price) || price < 0) {
return null;
}
return {
service_id: parseInt(l.service_id, 10),
description: l.description,
quantity: qty,
price_unit: price,
is_prescribed: l.is_prescribed,
prescribed_by_id: l.is_prescribed && l.prescribed_by_id ? parseInt(l.prescribed_by_id, 10) : null,
};
})
.filter((l): l is Omit<SaleLine, 'subtotal'> => l !== null);
if (parsedLines.some((l) => l === null)) {
toast.error('Revisa cantidades y precios de las líneas');
return;
}
if (parsedLines.length === 0) {
toast.error('Agrega al menos una línea');
return;
}
try {
setSubmitting(true);
await odooApi.createSale({
patient_id: parseInt(patientId, 10),
lines: parsedLines,
});
toast.success('Venta creada');
setCreateOpen(false);
setPatientId('');
setLines([emptyLine()]);
await loadSales();
} catch (err) {
toast.error('Error al crear venta');
console.error(err);
} finally {
setSubmitting(false);
}
};
const openPay = (sale: Sale) => {
setPayOpen(sale);
setPayMethod('Efectivo');
setPayAmount(String(sale.amount_due));
};
const pay = async () => {
if (!payOpen) return;
const amount = payAmount ? parseFloat(payAmount) : undefined;
if (amount !== undefined && (Number.isNaN(amount) || amount <= 0)) {
toast.error('Monto inválido');
return;
}
try {
setPaying(true);
await odooApi.paySale(payOpen.id, { payment_method: payMethod, amount });
toast.success('Pago registrado');
setPayOpen(null);
await loadSales();
} catch (err) {
toast.error('Error al pagar venta');
console.error(err);
} finally {
setPaying(false);
}
};
const openRefund = (sale: Sale) => {
setRefundOpen(sale);
setRefundAmount(String(sale.amount_paid));
setRefundReason('');
};
const submitRefund = async () => {
if (!refundOpen) return;
const amount = refundAmount ? parseFloat(refundAmount) : undefined;
if (amount !== undefined && (Number.isNaN(amount) || amount <= 0)) {
toast.error('Monto inválido');
return;
}
if (amount !== undefined && amount > refundOpen.amount_paid) {
toast.error(`El monto no puede exceder lo pagado ($${refundOpen.amount_paid})`);
return;
}
try {
setRefunding(true);
const res = await odooApi.refundSale(refundOpen.id, { amount, reason: refundReason || undefined });
const pts = res.result?.points_reversed ?? 0;
toast.success(`Devolución registrada${pts > 0 ? ` · ${pts} pts revertidos` : ''}`);
setRefundOpen(null);
await loadSales();
} catch (err) {
toast.error('Error al registrar devolución');
console.error(err);
} finally {
setRefunding(false);
}
};
const exportSales = () => {
const rows: (string | number)[][] = [
['Referencia', 'Paciente', 'Fecha', 'Subtotal', 'Descuento', 'Impuesto', 'Total', 'Pagado', 'Por cobrar', 'Estado', 'Devuelta', 'Monto devuelto'],
];
sales.forEach((s) =>
rows.push([
s.name, s.patient, s.date, s.subtotal, s.discount, s.tax, s.total,
s.amount_paid, s.amount_due, s.state, s.refunded ? 'Sí' : 'No', s.refund_amount || 0,
])
);
rows.push(['', '', 'Totales', '', '', '', sales.reduce((a, s) => a + s.total, 0), sales.reduce((a, s) => a + s.amount_paid, 0), sales.reduce((a, s) => a + s.amount_due, 0), '', '', sales.reduce((a, s) => a + (s.refund_amount || 0), 0)]);
downloadCsv(`ventas-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
toast.success(`CSV descargado (${sales.length} ventas de la vista actual)`);
};
const patientOptions = [
{ value: '', label: 'Seleccionar paciente...' },
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
];
const serviceOptions = [
{ value: '', label: 'Servicio...' },
...services.map((s) => ({ value: String(s.id), label: `${s.name}$${s.price}` })),
];
const doctorOptions = [
{ value: '', label: 'Médico que recetó...' },
...doctors.map((d) => ({ value: String(d.id), label: `${d.name} (${d.commission_pct ?? 0}%)` })),
];
const payMethodOptions = paymentMethods.map((m) => ({ value: m, label: m }));
return (
<Layout title="Ventas" subtitle="Órdenes de venta">
<PageHeader title="Ventas" subtitle="Crea ventas y registra pagos">
<Button variant="outline" onClick={exportSales} disabled={sales.length === 0}>
<Download size={16} className="mr-2" />
Exportar CSV
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus size={16} className="mr-2" />
Nueva venta
</Button>
</PageHeader>
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por referencia o paciente..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : sales.length === 0 ? (
<EmptyState
title="Sin ventas"
subtitle="No hay ventas registradas."
actionLabel="Nueva venta"
onAction={() => setCreateOpen(true)}
icon={<ShoppingCart size={28} />}
/>
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Total</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Pagado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{sales.map((s) => (
<tr key={s.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-muted">{s.name}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{s.patient}</td>
<td className="p-3 text-sm font-semibold text-theme-heading">${s.total}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">${s.amount_paid}</td>
<td className="p-3">
<div className="flex items-center gap-2">
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
</div>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
{s.amount_due > 0 && (
<Button variant="ghost" size="sm" onClick={() => openPay(s)} title="Pagar">
<CreditCard size={16} className="text-theme-heading mr-1.5" />
Pagar
</Button>
)}
{s.amount_paid > 0 && s.state !== 'cancelled' && (
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
<Undo2 size={16} className="text-amber-600 mr-1.5" />
Devolución
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{sales.map((s) => (
<MobileCard
key={s.id}
title={s.patient}
subtitle={s.name}
rows={[
{ label: 'Total', value: `$${s.total}` },
{ label: 'Pagado', value: `$${s.amount_paid}` },
{
label: 'Estado',
value: (
<div className="flex items-center gap-2">
<Badge variant={badgeForSaleState(s.state)}>{s.state}</Badge>
{s.refunded && <Badge variant="warning">Devuelta</Badge>}
</div>
),
},
]}
actions={
<div className="flex items-center gap-1">
{s.amount_due > 0 && (
<Button variant="ghost" size="sm" onClick={() => openPay(s)}>
<CreditCard size={16} className="text-theme-heading" />
</Button>
)}
{s.amount_paid > 0 && s.state !== 'cancelled' && (
<Button variant="ghost" size="sm" onClick={() => openRefund(s)} title="Devolución">
<Undo2 size={16} className="text-amber-600" />
</Button>
)}
</div>
}
/>
))}
</div>
{/* Paginación */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-theme-border">
<p className="text-sm text-theme-muted">
{total} ventas · página {page} de {totalPages || 1}
</p>
<div className="flex items-center gap-2">
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
className="border border-theme-border-strong rounded-lg px-2 py-1.5 text-sm"
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Anterior
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Siguiente
</Button>
</div>
</div>
</>
)}
</Card.Body>
</Card>
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
title="Nueva venta"
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={create} loading={submitting}>Crear venta</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Buscar paciente"
placeholder="Escribe nombre o teléfono..."
value={patientSearch}
onChange={(e) => setPatientSearch(e.target.value)}
/>
<Select
label="Paciente *"
options={patientOptions}
value={patientId}
onChange={(e) => setPatientId(e.target.value)}
/>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-theme-muted">Líneas</label>
<Button variant="outline" size="sm" onClick={addLine}>
<Plus size={14} className="mr-1" />
Agregar
</Button>
</div>
<div className="space-y-2">
{lines.map((line, i) => (
<div key={i} className="space-y-2">
<div className="grid grid-cols-12 gap-2 items-start">
<div className="col-span-12 sm:col-span-4">
<Select
options={serviceOptions}
value={line.service_id}
onChange={(e) => {
const sid = e.target.value;
const svc = services.find((s) => String(s.id) === sid);
updateLine(i, 'service_id', sid);
if (svc) {
updateLine(i, 'price_unit', String(svc.price));
updateLine(i, 'description', svc.name);
}
}}
/>
</div>
<Input
placeholder="Descripción"
value={line.description}
onChange={(e) => updateLine(i, 'description', e.target.value)}
className="col-span-12 sm:col-span-4"
/>
<Input
type="number"
placeholder="Cant"
value={line.quantity}
onChange={(e) => updateLine(i, 'quantity', e.target.value)}
className="col-span-4 sm:col-span-1"
/>
<Input
type="number"
placeholder="Precio"
value={line.price_unit}
onChange={(e) => updateLine(i, 'price_unit', e.target.value)}
className="col-span-6 sm:col-span-2"
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeLine(i)}
className="col-span-2 sm:col-span-1 h-[42px]"
title="Eliminar línea"
>
<Trash2 size={16} className="text-rose-600" />
</Button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pl-1">
<label className="inline-flex items-center gap-2 text-sm text-theme-muted cursor-pointer">
<input
type="checkbox"
checked={line.is_prescribed}
onChange={(e) => updateLine(i, 'is_prescribed', e.target.checked)}
className="rounded border-theme-border-strong"
/>
Artículo recetado
</label>
{line.is_prescribed && (
<Select
options={doctorOptions}
value={line.prescribed_by_id}
onChange={(e) => updateLine(i, 'prescribed_by_id', e.target.value)}
className="sm:max-w-xs"
/>
)}
</div>
</div>
))}
</div>
</div>
</div>
</Modal>
<Modal
isOpen={!!payOpen}
onClose={() => setPayOpen(null)}
title={`Pagar ${payOpen?.name || ''}`}
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setPayOpen(null)}>Cancelar</Button>
<Button onClick={pay} loading={paying}>Registrar pago</Button>
</>
}
>
{payOpen && (
<div className="space-y-4">
<p className="text-sm text-theme-muted">
Paciente: <span className="font-medium text-theme-heading">{payOpen.patient}</span>
</p>
<p className="text-sm text-theme-muted">
Total: <span className="font-medium text-theme-heading">${payOpen.total}</span>
{' · '}
Por pagar: <span className="font-medium text-theme-heading">${payOpen.amount_due}</span>
</p>
<Select
label="Método de pago"
options={payMethodOptions}
value={payMethod}
onChange={(e) => setPayMethod(e.target.value)}
/>
<Input
label="Monto"
type="number"
min={0.01}
step={0.01}
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
/>
</div>
)}
</Modal>
<Modal
isOpen={!!refundOpen}
onClose={() => setRefundOpen(null)}
title={`Devolución ${refundOpen?.name || ''}`}
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setRefundOpen(null)}>Cancelar</Button>
<Button variant="danger" onClick={submitRefund} loading={refunding}>Confirmar devolución</Button>
</>
}
>
{refundOpen && (
<div className="space-y-4">
<p className="text-sm text-theme-muted">
Paciente: <span className="font-medium text-theme-heading">{refundOpen.patient}</span>
</p>
<p className="text-sm text-theme-muted">
Total: <span className="font-medium text-theme-heading">${refundOpen.total}</span>
{' · '}
Pagado: <span className="font-medium text-theme-heading">${refundOpen.amount_paid}</span>
</p>
<div className="rounded-lg bg-amber-50 border border-theme-bg px-3 py-2 text-xs text-amber-800">
La devolución reduce el monto pagado y revierte los puntos del monedero en proporción ($10 = 1 pt). Deja el monto vacío o igual a lo pagado para devolución total.
</div>
<Input
label="Monto a devolver"
type="number"
min={0.01}
max={refundOpen.amount_paid}
step={0.01}
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
/>
<TextArea
label="Motivo"
placeholder="Motivo de la devolución..."
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
/>
</div>
)}
</Modal>
</Layout>
);
};
export default Ventas;

View File

@@ -0,0 +1,501 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import {
RefreshCw,
Target,
Search,
TrendingUp,
CheckCircle,
XCircle,
Circle,
LayoutGrid,
List,
MoreHorizontal,
User,
ArrowRightCircle,
} from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, Select, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
import { odooApi, type WacrmLead, type WacrmPipeline } from '../services/odoo';
const statusOptions = [
{ value: '', label: 'Todos los estados' },
{ value: 'open', label: 'Abierto' },
{ value: 'won', label: 'Ganado' },
{ value: 'lost', label: 'Perdido' },
];
const statusBadge = (status: string) => {
switch (status) {
case 'won':
return 'success';
case 'lost':
return 'danger';
default:
return 'primary';
}
};
const statusIcon = (status: string) => {
switch (status) {
case 'won':
return <CheckCircle size={14} className="mr-1" />;
case 'lost':
return <XCircle size={14} className="mr-1" />;
default:
return <Circle size={14} className="mr-1" />;
}
};
const currencySymbol = (currency: string) => {
if (currency === 'MXN') return '$';
if (currency === 'USD') return 'US$';
return currency;
};
const WacrmLeads: FC = () => {
const [leads, setLeads] = useState<WacrmLead[]>([]);
const [pipelines, setPipelines] = useState<WacrmPipeline[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
const [pipelineId, setPipelineId] = useState('');
const [view, setView] = useState<'list' | 'kanban'>('kanban');
const [lastSync, setLastSync] = useState<Date | null>(null);
const [selectedLead, setSelectedLead] = useState<WacrmLead | null>(null);
const [stageModalOpen, setStageModalOpen] = useState(false);
const [movingStage, setMovingStage] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setLoading(true);
const [leadsRes, pipelinesRes] = await Promise.all([
odooApi.getWacrmLeads(search || undefined, status || undefined, pipelineId || undefined),
odooApi.getWacrmPipelines(),
]);
if (leadsRes.status === 'success') setLeads(leadsRes.leads);
if (pipelinesRes.status === 'success') setPipelines(pipelinesRes.pipelines);
setLastSync(new Date());
} catch (err) {
toast.error('Error al cargar leads');
console.error(err);
} finally {
setLoading(false);
}
}, [search, status, pipelineId]);
const sync = async () => {
try {
setSyncing(true);
const res = await odooApi.syncWacrm();
if (res.status === 'success') {
toast.success(`Sincronizado: ${res.result.deals || 0} deals`);
await load();
}
} catch (err) {
toast.error('Error al sincronizar con WACRM');
console.error(err);
} finally {
setSyncing(false);
}
};
const updateStatus = async (lead: WacrmLead, newStatus: 'open' | 'won' | 'lost') => {
try {
const res = await odooApi.updateWacrmLeadStatus(lead.id, { status: newStatus });
if (res.status === 'success') {
toast.success(`Lead marcado como ${newStatus}`);
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
}
} catch (err) {
toast.error('Error al actualizar lead');
console.error(err);
}
};
const changeStage = async (lead: WacrmLead, stageExternalId: string) => {
try {
setMovingStage(stageExternalId);
const res = await odooApi.updateWacrmLeadStatus(lead.id, { stage_id: stageExternalId });
if (res.status === 'success') {
toast.success('Etapa actualizada');
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
setStageModalOpen(false);
setSelectedLead(null);
}
} catch (err) {
toast.error('Error al cambiar etapa');
console.error(err);
} finally {
setMovingStage(null);
}
};
useEffect(() => {
load();
}, [load]);
// Auto-sync cada 15 segundos
useEffect(() => {
const interval = setInterval(() => {
load();
}, 15000);
return () => clearInterval(interval);
}, [load]);
const pipelineOptions = useMemo(
() => [{ value: '', label: 'Todos los pipelines' }, ...pipelines.map((p) => ({ value: p.external_id, label: p.name }))],
[pipelines]
);
const activePipeline = useMemo(
() => pipelines.find((p) => p.external_id === pipelineId) || pipelines[0],
[pipelines, pipelineId]
);
const filteredLeads = useMemo(() => {
if (!activePipeline) return leads;
return leads.filter((l) => l.pipeline_id === activePipeline.id);
}, [leads, activePipeline]);
const stats = useMemo(() => {
const open = leads.filter((l) => l.status === 'open');
const won = leads.filter((l) => l.status === 'won');
const lost = leads.filter((l) => l.status === 'lost');
return {
total: leads.length,
openValue: open.reduce((sum, l) => sum + (l.value || 0), 0),
wonValue: won.reduce((sum, l) => sum + (l.value || 0), 0),
lostValue: lost.reduce((sum, l) => sum + (l.value || 0), 0),
openCount: open.length,
wonCount: won.length,
lostCount: lost.length,
};
}, [leads]);
const renderList = () => (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Lead</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Contacto</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Pipeline / Etapa</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Valor</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{filteredLeads.map((l) => (
<tr key={l.external_id} className="hover:bg-theme-bg">
<td className="p-3">
<p className="text-sm font-medium text-theme-heading">{l.title}</p>
<p className="text-xs text-theme-muted line-clamp-1">{l.notes || 'Sin notas'}</p>
</td>
<td className="p-3 hidden sm:table-cell">
<p className="text-sm text-theme-heading">{l.contact_name || '—'}</p>
<p className="text-xs text-theme-muted">{l.contact_phone || '—'}</p>
</td>
<td className="p-3 hidden md:table-cell">
<p className="text-xs text-theme-muted">{l.pipeline || '—'}</p>
<button
onClick={() => {
setSelectedLead(l);
setStageModalOpen(true);
}}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium hover:opacity-80 transition"
style={{ backgroundColor: `${l.stage_color}20`, color: l.stage_color }}
>
{l.stage || '—'} <ArrowRightCircle size={12} className="ml-1" />
</button>
</td>
<td className="p-3">
<p className="text-sm font-medium text-theme-heading">
{currencySymbol(l.currency)}{l.value.toLocaleString()}
</p>
</td>
<td className="p-3">
<Badge variant={statusBadge(l.status)}>
{statusIcon(l.status)}
{l.status}
</Badge>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
{l.status !== 'won' && (
<button
onClick={() => updateStatus(l, 'won')}
className="p-1.5 text-theme-heading hover:bg-theme-success rounded"
title="Marcar ganado"
>
<CheckCircle size={16} />
</button>
)}
{l.status !== 'lost' && (
<button
onClick={() => updateStatus(l, 'lost')}
className="p-1.5 text-rose-600 hover:bg-rose-50 rounded"
title="Marcar perdido"
>
<XCircle size={16} />
</button>
)}
{l.status !== 'open' && (
<button
onClick={() => updateStatus(l, 'open')}
className="p-1.5 text-theme-muted hover:bg-theme-bg rounded"
title="Reabrir"
>
<Circle size={16} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
const renderKanban = () => {
if (!activePipeline) return <EmptyState title="Sin pipeline" subtitle="Selecciona un pipeline para ver el Kanban." />;
return (
<div className="overflow-x-auto pb-2">
<div className="flex gap-4 min-w-max">
{activePipeline.stages.map((stage) => {
const stageLeads = filteredLeads.filter((l) => l.stage_id === stage.id);
const stageValue = stageLeads.reduce((sum, l) => sum + (l.value || 0), 0);
return (
<div key={stage.external_id} className="w-72 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: stage.color }} />
<h3 className="text-sm font-semibold text-theme-heading">{stage.name}</h3>
<span className="text-xs text-theme-muted bg-theme-bg px-1.5 py-0.5 rounded-full">{stageLeads.length}</span>
</div>
</div>
<div className="space-y-3">
{stageLeads.map((l) => (
<div key={l.external_id} style={{ borderLeftColor: stage.color }}>
<Card className="border-l-4">
<Card.Body className="p-3">
<div className="flex items-start justify-between gap-2 mb-2">
<p className="text-sm font-medium text-theme-heading line-clamp-2">{l.title}</p>
<Badge variant={statusBadge(l.status)} className="text-[10px] px-1.5 py-0">
{statusIcon(l.status)}
</Badge>
</div>
<div className="flex items-center gap-1.5 text-xs text-theme-muted mb-2">
<User size={12} />
<span className="truncate">{l.contact_name || l.contact_phone || '—'}</span>
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-theme-heading">
{currencySymbol(l.currency)}{l.value.toLocaleString()}
</p>
<button
onClick={() => {
setSelectedLead(l);
setStageModalOpen(true);
}}
className="p-1 text-theme-muted hover:text-theme-heading hover:bg-theme-bg rounded"
>
<MoreHorizontal size={16} />
</button>
</div>
<div className="flex items-center gap-1 mt-2">
{l.status !== 'won' && (
<button
onClick={() => updateStatus(l, 'won')}
className="flex-1 py-1 text-[10px] font-medium text-theme-heading bg-theme-success hover:bg-theme-success rounded"
>
Ganar
</button>
)}
{l.status !== 'lost' && (
<button
onClick={() => updateStatus(l, 'lost')}
className="flex-1 py-1 text-[10px] font-medium text-rose-700 bg-rose-50 hover:bg-rose-100 rounded"
>
Perder
</button>
)}
</div>
</Card.Body>
</Card>
</div>
))}
{stageLeads.length === 0 && (
<div className="text-center py-6 text-xs text-theme-muted border border-dashed border-theme-border-strong rounded-lg">
Sin leads en esta etapa
</div>
)}
</div>
{stageValue > 0 && (
<p className="mt-2 text-xs text-theme-muted text-right">
Total: {currencySymbol(activePipeline.stages[0]?.color ? 'MXN' : 'MXN')}{stageValue.toLocaleString()}
</p>
)}
</div>
);
})}
</div>
</div>
);
};
return (
<Layout title="WACRM — Leads" subtitle="Deals y oportunidades del CRM">
{/* Métricas */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4 sm:mb-6">
<Card>
<Card.Body className="flex items-center gap-3">
<div className="p-2 bg-theme-bg rounded-lg">
<Target size={20} className="text-theme-heading" />
</div>
<div>
<p className="text-xs text-theme-muted">Total leads</p>
<p className="text-lg font-semibold text-theme-heading">{stats.total}</p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<TrendingUp size={20} className="text-blue-600" />
</div>
<div>
<p className="text-xs text-theme-muted">Valor abierto</p>
<p className="text-lg font-semibold text-theme-heading">${stats.openValue.toLocaleString()}</p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body className="flex items-center gap-3">
<div className="p-2 bg-theme-success rounded-lg">
<CheckCircle size={20} className="text-theme-heading" />
</div>
<div>
<p className="text-xs text-theme-muted">Ganados</p>
<p className="text-lg font-semibold text-theme-heading">{stats.wonCount} <span className="text-xs font-normal text-theme-muted">(${stats.wonValue.toLocaleString()})</span></p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body className="flex items-center gap-3">
<div className="p-2 bg-rose-50 rounded-lg">
<XCircle size={20} className="text-rose-600" />
</div>
<div>
<p className="text-xs text-theme-muted">Perdidos</p>
<p className="text-lg font-semibold text-theme-heading">{stats.lostCount}</p>
</div>
</Card.Body>
</Card>
</div>
<Card>
<Card.Body>
{/* Filtros */}
<div className="flex flex-col lg:flex-row gap-3 mb-4">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar lead o contacto..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select
value={status}
onChange={(e) => setStatus(e.target.value)}
options={statusOptions}
className="w-full lg:w-44"
/>
<Select
value={pipelineId}
onChange={(e) => setPipelineId(e.target.value)}
options={pipelineOptions}
className="w-full lg:w-52"
/>
<div className="flex items-center gap-2">
<div className="inline-flex rounded-lg border border-theme-border-strong overflow-hidden">
<button
onClick={() => setView('kanban')}
className={`px-3 py-2 flex items-center gap-1.5 text-sm ${view === 'kanban' ? 'bg-[#2D2D2D] text-white' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'}`}
>
<LayoutGrid size={16} /> Kanban
</button>
<button
onClick={() => setView('list')}
className={`px-3 py-2 flex items-center gap-1.5 text-sm ${view === 'list' ? 'bg-[#2D2D2D] text-white' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'}`}
>
<List size={16} /> Lista
</button>
</div>
<Button onClick={sync} loading={syncing}>
<RefreshCw size={16} className="mr-2" />
Sincronizar
</Button>
</div>
</div>
{lastSync && (
<p className="text-xs text-theme-muted mb-3">
Última sincronización: {lastSync.toLocaleTimeString()} · se actualiza automáticamente cada 15s
</p>
)}
{loading && leads.length === 0 ? (
<Skeleton count={6} className="h-14 w-full" />
) : filteredLeads.length === 0 ? (
<EmptyState
title="Sin leads"
subtitle="No hay deals sincronizados. Presiona Sincronizar para traer datos de WACRM."
icon={<Target size={28} />}
actionLabel="Sincronizar"
onAction={sync}
/>
) : view === 'list' ? (
renderList()
) : (
renderKanban()
)}
</Card.Body>
</Card>
{/* Modal cambiar etapa */}
<Modal isOpen={stageModalOpen} onClose={() => setStageModalOpen(false)} title="Mover a etapa">
<div className="space-y-2 max-h-96 overflow-y-auto">
{activePipeline?.stages.map((stage) => {
const isCurrent = selectedLead?.stage_id === stage.id;
return (
<button
key={stage.external_id}
disabled={isCurrent || !!movingStage}
onClick={() => selectedLead && changeStage(selectedLead, stage.external_id)}
className={`w-full flex items-center justify-between p-3 rounded-lg border text-left transition ${
isCurrent
? 'bg-theme-bg border-theme-border-strong text-theme-muted cursor-not-allowed'
: 'bg-theme-surface border-theme-border-strong hover:border-[#D6D3D1] hover:bg-theme-bg'
}`}
>
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: stage.color }} />
<span className="text-sm font-medium text-theme-heading">{stage.name}</span>
</div>
{isCurrent && <span className="text-xs text-theme-muted">Actual</span>}
</button>
);
})}
</div>
</Modal>
</Layout>
);
};
export default WacrmLeads;

View File

@@ -0,0 +1,452 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
import { odooApi, type WacrmConversation, type WacrmMessage } from '../services/odoo';
interface Member {
id: string;
name: string;
email?: string;
avatar_url?: string;
role: string;
}
const WacrmMessages: FC = () => {
const [conversations, setConversations] = useState<WacrmConversation[]>([]);
const [messages, setMessages] = useState<WacrmMessage[]>([]);
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<WacrmConversation | null>(null);
const [assignOpen, setAssignOpen] = useState(false);
const [assigning, setAssigning] = useState(false);
const [replyText, setReplyText] = useState('');
const [sendingReply, setSendingReply] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('skeen_current_agent_id') || '';
}
return '';
});
// Refs para evitar loops y dependency churn
const selectedRef = useRef<WacrmConversation | null>(null);
const searchRef = useRef(search);
const userSelectedRef = useRef(false);
useEffect(() => {
selectedRef.current = selected;
}, [selected]);
useEffect(() => {
searchRef.current = search;
}, [search]);
useEffect(() => {
if (currentAgentId && typeof window !== 'undefined') {
localStorage.setItem('skeen_current_agent_id', currentAgentId);
}
}, [currentAgentId]);
const loadConversations = useCallback(async () => {
try {
const res = await odooApi.getWacrmConversations(searchRef.current);
if (res.status === 'success') {
setConversations(res.conversations);
const currentSelected = selectedRef.current;
if (currentSelected) {
const updated = res.conversations.find((c) => c.external_id === currentSelected.external_id);
if (updated && updated.external_id !== currentSelected.external_id) {
// Solo actualizar si realmente cambió la referencia (datos nuevos)
setSelected(updated);
}
} else if (!userSelectedRef.current && res.conversations.length > 0) {
setSelected(res.conversations[0]);
}
}
} catch (err) {
toast.error('Error al cargar conversaciones');
console.error(err);
}
}, []);
const loadMessages = useCallback(async (conversationId: string) => {
try {
const res = await odooApi.getWacrmMessages(undefined, conversationId);
if (res.status === 'success') {
setMessages(res.messages.reverse());
}
} catch (err) {
toast.error('Error al cargar mensajes');
console.error(err);
}
}, []);
const loadMembers = useCallback(async () => {
try {
const res = await odooApi.getWacrmMembers();
if (res.status === 'success') {
setMembers(res.members);
}
} catch (err) {
console.error('Error cargando agentes:', err);
}
}, []);
const sync = async () => {
try {
setSyncing(true);
const res = await odooApi.syncWacrm();
if (res.status === 'success') {
toast.success(`Sincronizado: ${JSON.stringify(res.result)}`);
await loadConversations();
}
} catch (err) {
toast.error('Error al sincronizar con WACRM');
console.error(err);
} finally {
setSyncing(false);
}
};
// Carga inicial
useEffect(() => {
let mounted = true;
const init = async () => {
setLoading(true);
await Promise.all([loadConversations(), loadMembers()]);
if (mounted) setLoading(false);
};
init();
return () => {
mounted = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Cuando cambia la búsqueda, recargar lista pero no forzar selección
useEffect(() => {
loadConversations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
// Cargar mensajes cuando cambia la conversación seleccionada
useEffect(() => {
if (selected) {
loadMessages(selected.external_id);
}
}, [selected, loadMessages]);
// Auto-sync cada 15 segundos
useEffect(() => {
const interval = setInterval(() => {
loadConversations();
const currentSelected = selectedRef.current;
if (currentSelected) {
loadMessages(currentSelected.external_id);
}
}, 15000);
return () => clearInterval(interval);
}, [loadConversations, loadMessages]);
const handleSelect = useCallback((c: WacrmConversation) => {
userSelectedRef.current = true;
setSelected(c);
}, []);
const handleSendReply = async () => {
if (!selected || !replyText.trim()) return;
if (!currentAgentId) {
toast.error('Selecciona tu identidad de agente antes de responder');
return;
}
try {
setSendingReply(true);
await odooApi.sendWacrmMessage({
conversation_id: selected.external_id,
text: replyText.trim(),
assigned_agent_id: currentAgentId,
});
toast.success('Mensaje enviado');
setReplyText('');
await loadMessages(selected.external_id);
await loadConversations();
} catch (err) {
toast.error('Error al enviar mensaje');
console.error(err);
} finally {
setSendingReply(false);
}
};
const handleAssign = async (memberId: string | null) => {
if (!selected) return;
try {
setAssigning(true);
await odooApi.assignWacrmConversation(selected.external_id, memberId);
toast.success(memberId ? 'Conversación asignada' : 'Conversación desasignada');
await loadConversations();
setAssignOpen(false);
} catch (err) {
toast.error('Error al asignar conversación');
console.error(err);
} finally {
setAssigning(false);
}
};
const senderIcon = (type: string) => {
if (type === 'bot') return <Bot size={14} className="text-theme-heading" />;
if (type === 'agent') return <Headphones size={14} className="text-blue-600" />;
return <User size={14} className="text-theme-muted" />;
};
const senderLabel = (type: string) => {
if (type === 'bot') return 'Sofía (Bot)';
if (type === 'agent') return 'Agente';
return 'Cliente';
};
const assignedMember = members.find((m) => m.id === selected?.assigned_agent_id);
return (
<Layout title="WACRM — Mensajes" subtitle="Conversaciones de WhatsApp sincronizadas">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4 sm:mb-6">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar conversación..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<select
value={currentAgentId}
onChange={(e) => setCurrentAgentId(e.target.value)}
className="appearance-none bg-theme-surface border border-theme-border-strong text-theme-heading text-sm rounded-lg pl-3 pr-8 py-2 focus:outline-none focus:ring-2 focus:ring-[#1A1A1A]"
>
<option value="">Responder como...</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-theme-muted pointer-events-none" />
</div>
<Button onClick={sync} loading={syncing}>
<RefreshCw size={16} className="mr-2" />
Sincronizar
</Button>
</div>
</div>
{loading ? (
<Skeleton count={6} className="h-20 w-full" />
) : conversations.length === 0 ? (
<EmptyState
title="Sin conversaciones"
subtitle="No hay conversaciones sincronizadas. Presiona Sincronizar para traer datos de WACRM."
icon={<MessageSquare size={28} />}
actionLabel="Sincronizar"
onAction={sync}
/>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6 h-[calc(100vh-220px)] min-h-[400px]">
{/* Lista de conversaciones */}
<Card className="lg:col-span-1 overflow-hidden flex flex-col">
<Card.Body className="p-0 overflow-y-auto flex-1">
<div className="divide-y">
{conversations.map((c) => (
<button
key={c.external_id}
onClick={() => handleSelect(c)}
className={`w-full text-left p-3 sm:p-4 transition hover:bg-theme-bg ${
selected?.external_id === c.external_id ? 'bg-theme-bg' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{c.contact_name || 'Desconocido'}</p>
<p className="text-xs text-theme-muted flex items-center mt-0.5">
<Phone size={10} className="mr-1" />
{c.contact_phone || '—'}
</p>
</div>
{c.unread_count > 0 && (
<Badge variant="primary">{c.unread_count}</Badge>
)}
</div>
<p className="text-xs text-theme-muted mt-1 truncate">{c.last_message_text || 'Sin mensajes'}</p>
<div className="flex items-center justify-between mt-1">
<p className="text-[10px] text-theme-muted">{c.last_message_at || c.created_at || ''}</p>
{c.assigned_agent_id && (
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full truncate max-w-[120px]">
Asignado
</span>
)}
</div>
</button>
))}
</div>
</Card.Body>
</Card>
{/* Mensajes */}
<Card className="lg:col-span-2 overflow-hidden flex flex-col">
<Card.Body className="p-0 flex flex-col h-full">
{selected ? (
<>
<div className="p-4 border-b border-theme-border bg-theme-bg flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading">{selected.contact_name || 'Desconocido'}</p>
<p className="text-xs text-theme-muted">{selected.contact_phone}</p>
{assignedMember && (
<p className="text-xs text-blue-600 mt-0.5">
Asignado a: {assignedMember.name}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setAssignOpen(true)}
className="shrink-0"
>
<UserPlus size={14} className="mr-1.5" />
{selected.assigned_agent_id ? 'Reasignar' : 'Asignar'}
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 ? (
<p className="text-center text-sm text-theme-muted py-8">No hay mensajes en esta conversación</p>
) : (
messages.map((m) => (
<div
key={m.external_id}
className={`flex ${
m.sender_type === 'customer' ? 'justify-start' : 'justify-end'
}`}
>
<div
className={`max-w-[85%] sm:max-w-[70%] rounded-2xl px-4 py-2.5 text-sm ${
m.sender_type === 'customer'
? 'bg-theme-surface border border-theme-border-strong text-theme-heading'
: 'bg-[#1A1A1A] text-white'
}`}
>
<div className="flex items-center gap-1.5 mb-1 opacity-80">
{senderIcon(m.sender_type)}
<span className="text-[10px] font-medium">{senderLabel(m.sender_type)}</span>
</div>
<p>{m.content_text}</p>
{m.media_url && (
<a
href={m.media_url}
target="_blank"
rel="noreferrer"
className="text-xs underline mt-1 block truncate"
>
Ver medio
</a>
)}
<p className="text-[10px] mt-1 opacity-60 text-right">{m.created_at}</p>
</div>
</div>
))
)}
</div>
{/* Input de respuesta */}
<div className="p-3 border-t border-theme-border bg-theme-bg">
<div className="flex items-end gap-2">
<Input
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendReply();
}
}}
placeholder={currentAgentId ? 'Escribe tu respuesta...' : 'Selecciona tu identidad de agente'}
disabled={!currentAgentId || sendingReply}
className="flex-1"
/>
<Button
onClick={handleSendReply}
loading={sendingReply}
disabled={!currentAgentId || !replyText.trim()}
>
<Send size={16} className="mr-1.5" />
Enviar
</Button>
</div>
{!currentAgentId && (
<p className="text-xs text-amber-600 mt-2">Selecciona "Responder como..." para poder responder mensajes.</p>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-theme-muted">Selecciona una conversación</p>
</div>
)}
</Card.Body>
</Card>
</div>
)}
{/* Modal de asignación */}
<Modal
isOpen={assignOpen}
onClose={() => setAssignOpen(false)}
title="Asignar conversación"
maxWidth="sm"
>
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
{selected?.assigned_agent_id && (
<button
onClick={() => handleAssign(null)}
disabled={assigning}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-red-100 bg-red-50 hover:bg-red-100 transition text-left"
>
<X size={18} className="text-red-600" />
<span className="text-sm font-medium text-red-700">Desasignar conversación</span>
</button>
)}
{members.length === 0 ? (
<p className="text-sm text-theme-muted py-4 text-center">No hay agentes disponibles</p>
) : (
members.map((member) => (
<button
key={member.id}
onClick={() => handleAssign(member.id)}
disabled={assigning || selected?.assigned_agent_id === member.id}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition text-left ${
selected?.assigned_agent_id === member.id
? 'border-blue-200 bg-blue-50'
: 'border-theme-border hover:bg-theme-bg'
}`}
>
<div className="w-8 h-8 rounded-full bg-theme-border flex items-center justify-center text-xs font-medium text-theme-heading shrink-0">
{member.name.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{member.name}</p>
<p className="text-xs text-theme-muted truncate">{member.role}</p>
</div>
</button>
))
)}
</div>
</Modal>
</Layout>
);
};
export default WacrmMessages;

View File

@@ -0,0 +1,748 @@
import axios from 'axios';
const ODOO_BASE = import.meta.env.VITE_API_BASE_URL || '/api/odoo';
// ============================================================
// Auth: adjuntar token y manejar 401 globalmente
// ============================================================
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('skeen_token');
if (token) {
config.headers = config.headers || {};
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
}
return config;
});
axios.interceptors.response.use(
(res) => res,
(error) => {
if (error?.response?.status === 401) {
const onLogin = window.location.pathname === '/login';
const isLoginCall = String(error.config?.url || '').includes('/auth/login');
if (!onLogin && !isLoginCall) {
localStorage.removeItem('skeen_token');
localStorage.removeItem('skeen_user');
window.location.href = '/login';
}
}
return Promise.reject(error);
},
);
// ============================================================
// Tipos
// ============================================================
export interface DashboardStats {
appointments_today: number;
appointments_month: number;
new_patients_month: number;
total_patients: number;
revenue_month: number;
pending_payments: number;
total_wallet_points: number;
sales_today: number;
}
export interface Appointment {
id: number;
reference: string;
patient_id: number;
patient: string;
phone: string;
service_id: number;
service: string;
service_category?: string;
date: string;
time: string;
state: string;
payment_state: string;
price: number;
doctor_id?: number;
doctor?: string;
branch?: string;
medium?: string;
notes?: string;
package_finished?: boolean;
package_finished_date?: string | null;
}
export interface Patient {
id: number;
patient_id: string;
name: string;
phone: string;
email: string;
birth_date: string | null;
age?: number;
gender?: string | false;
blood_type?: string | false;
last_visit: string | null;
total_visits: number;
wallet_points: number;
total_spent: number;
source: string;
is_vip: boolean;
primary_doctor_id?: number;
primary_doctor?: string;
// Datos personales extendidos
birthplace?: string;
occupation?: string;
marital_status?: string;
emergency_contact?: string;
emergency_phone?: string;
home_phone?: string;
mobile?: string;
address_notes?: string;
referred_by?: string;
patient_comments?: string;
internal_notes?: string;
// Historia clínica
is_pregnant?: boolean;
is_breastfeeding?: boolean;
uses_contraceptives?: boolean;
children_count?: number;
kidney_problems?: boolean;
back_pain?: boolean;
heart_disease?: boolean;
respiratory_problems?: boolean;
blood_pressure?: boolean;
diabetes?: boolean;
thyroid?: boolean;
colitis?: boolean;
constipation?: boolean;
liver_problems?: boolean;
surgeries?: boolean;
surgeries_notes?: string;
varicose_veins?: boolean;
migraine?: boolean;
faints_with_needles?: boolean;
medical_notes?: string;
// Legacy medical fields
allergies?: string;
medical_history?: string;
current_medication?: string;
}
export interface Service {
id: number;
code: string;
name: string;
category: string;
price: number;
package_price?: number;
package_sessions?: number;
package_notes?: string;
duration_min: number;
description: string;
color: string;
service_group: string;
is_favorite: boolean;
}
export interface Doctor {
id: number;
name: string;
job_title: string;
work_phone: string;
work_email: string;
commission_pct: number;
}
export interface Product {
id: number;
name: string;
list_price: number;
default_code: string;
type: string;
}
export interface Wallet {
id: number;
patient_id: number;
patient: string;
phone: string;
points: number;
equivalent_mxn: number;
}
export interface Payment {
id: number;
name: string;
patient: string;
patient_id: number;
amount: number;
payment_method: string;
state: string;
payment_url?: string;
provider_reference?: string;
create_date?: string;
}
export interface SaleLine {
id?: number;
service_id: number;
service?: string;
description: string;
quantity: number;
price_unit: number;
subtotal: number;
is_prescribed?: boolean;
prescribed_by_id?: number | null;
prescribed_by?: string | null;
}
export interface CommissionRow {
doctor_id: number;
doctor: string;
job_title: string;
commission_pct: number;
base: number;
commission: number;
items: number;
sales: number;
}
export interface Sale {
id: number;
name: string;
patient_id: number;
patient: string;
date: string;
subtotal: number;
discount: number;
tax: number;
total: number;
amount_paid: number;
amount_due: number;
state: string;
lines: SaleLine[];
refunded?: boolean;
refund_amount?: number;
refund_reason?: string;
refunded_at?: string | null;
}
export interface CashClosing {
id: number;
name: string;
date: string;
user: string | false;
opening_cash: number;
total_cash: number;
total_card: number;
total_transfer: number;
total_other: number;
total_sales: number;
closing_cash: number;
difference: number;
state: string;
}
export interface ChartPoint {
name: string;
appointments: number;
revenue: number;
patients: number;
}
export interface Birthday {
id: number;
name: string;
phone: string;
birth_date: string;
occurs_on: string;
turning_age: number;
last_visit: string | null;
total_spent: number;
amount_due: number;
is_vip: boolean;
}
export type InventoryKind = 'producto' | 'consumible';
export type InventoryLevel = 'out' | 'critical' | 'low' | 'optimal';
export type InventoryMoveType = 'compra' | 'venta' | 'baja' | 'ajuste';
export interface InventoryItem {
id: number;
name: string;
kind: InventoryKind;
sku: string;
category: string;
unit: string;
qty: number;
qty_optimal: number;
qty_min: number;
cost: number;
inventory_value: number;
expiry_date: string | null;
last_count_date: string | null;
active: boolean;
notes: string;
stock_level: InventoryLevel;
}
export interface InventoryMove {
id: number;
item_id: number;
type: InventoryMoveType;
qty: number;
before_qty: number;
after_qty: number;
date: string | null;
reference: string;
notes: string;
}
export interface InventorySummary {
levels: Record<InventoryLevel, number>;
total_value: number;
count: number;
}
// WACRM
export interface WacrmConversation {
id: number;
external_id: string;
contact_id?: string;
contact_name: string;
contact_phone: string;
status: string;
assigned_agent: string;
assigned_agent_id: string;
last_message_text: string;
last_message_at: string | null;
unread_count: number;
created_at: string | null;
}
export interface WacrmMessage {
id: number;
external_id: string;
conversation_id: string;
contact_name: string;
contact_phone: string;
sender_type: 'customer' | 'agent' | 'bot';
content_type: string;
content_text: string;
media_url: string;
template_name: string;
status: string;
created_at: string | null;
}
export interface WacrmStage {
id: number;
external_id: string;
name: string;
position: number;
color: string;
}
export interface WacrmPipeline {
id: number;
external_id: string;
name: string;
stages: WacrmStage[];
}
export interface WacrmLead {
id: number;
external_id: string;
title: string;
contact_id?: string;
contact_name: string;
contact_phone: string;
pipeline: string;
pipeline_id?: number;
stage: string;
stage_id?: number;
stage_color: string;
value: number;
currency: string;
status: 'open' | 'won' | 'lost';
expected_close_date: string | null;
notes: string;
assigned_to: string;
created_at: string | null;
}
export type FrontendRole = 'admin' | 'recepcion' | 'medico' | 'lectura';
export interface FrontendUser {
id: number;
login: string;
name: string;
role: FrontendRole;
must_change_password?: boolean;
last_login?: string | null;
active?: boolean;
}
export interface ApiResponse<T> {
status: string;
message?: string;
data?: T;
}
export interface Paginated<T> {
status: string;
total: number;
page: number;
page_size: number;
total_pages: number;
items: T;
}
// ============================================================
// API
// ============================================================
export const odooApi = {
// Auth (frontend React)
async login(login: string, password: string): Promise<{ status: string; token: string; user: FrontendUser; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/auth/login`, { login, password });
return data;
},
async me(): Promise<{ status: string; user: FrontendUser }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/auth/me`);
return data;
},
async logout(): Promise<{ status: string }> {
try {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/auth/logout`);
return data;
} catch {
return { status: 'success' };
}
},
async listFrontendUsers(): Promise<{ status: string; users: FrontendUser[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/auth/users`);
return data;
},
async createFrontendUser(payload: { login: string; name: string; password: string; role: FrontendRole }): Promise<{ status: string; user: FrontendUser; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/auth/users`, payload);
return data;
},
async updateFrontendUser(id: number, payload: Partial<{ name: string; role: FrontendRole; active: boolean; password: string; must_change_password: boolean }>): Promise<{ status: string; user: FrontendUser; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`, payload);
return data;
},
// Health
async healthCheck(): Promise<unknown> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`);
return data;
},
// Dashboard
async getDashboard(): Promise<{ status: string; stats: DashboardStats }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/dashboard`);
return data;
},
async getWeeklyChart(): Promise<{ status: string; data: ChartPoint[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/weekly_chart`);
return data;
},
// Citas
async getAppointments(params?: Record<string, string | number>): Promise<{ status: string; appointments: Appointment[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments`, { params });
return data;
},
async createAppointment(appointment: Partial<Appointment> & { sessions?: number }): Promise<{ status: string; appointment: Appointment; appointments?: Appointment[]; sessions?: number }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments`, appointment);
return data;
},
async updateAppointment(id: number, appointment: Partial<Appointment>): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}`, appointment);
return data;
},
async deleteAppointment(id: number): Promise<{ status: string; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}`);
return data;
},
async updateAppointmentStatus(id: number, action: string, reason?: string): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/status`, { action, reason });
return data;
},
async packageFinished(id: number, finished = true): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/package_finished`, { finished });
return data;
},
async getAvailableSlots(date: string, servicio_id: number): Promise<{ status: string; slots: { time: number; time_str: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments/slots`, { params: { date, servicio_id } });
return data;
},
// Pacientes
async getPatients(params?: string | { search?: string; page?: number; page_size?: number }): Promise<{ status: string; patients: Patient[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const p = typeof params === 'string' ? { search: params } : params || {};
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients`, { params: p });
return data;
},
async createPatient(patient: Partial<Patient>): Promise<{ status: string; patient: Patient }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients`, patient);
return data;
},
async updatePatient(id: number, patient: Partial<Patient>): Promise<{ status: string; patient: Patient }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}`, patient);
return data;
},
async getPatientHistory(id: number): Promise<{ status: string; appointments: Appointment[]; sales: Sale[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}/history`);
return data;
},
// Servicios
async getServices(): Promise<{ status: string; services: Service[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/services`);
return data;
},
async createService(service: Partial<Service>): Promise<{ status: string; service: Service }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/services`, service);
return data;
},
async updateService(id: number, service: Partial<Service>): Promise<{ status: string; service: Service }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/services/${id}`, service);
return data;
},
// Médicos
async getDoctors(): Promise<{ status: string; doctors: Doctor[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/doctors`);
return data;
},
async updateDoctor(id: number, payload: { commission_pct: number }): Promise<{ status: string; doctor: Doctor }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/doctors/${id}`, payload);
return data;
},
// Comisiones
async getCommissions(start?: string, end?: string): Promise<{ status: string; start: string; end: string; total_base: number; total_commission: number; commissions: CommissionRow[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/commissions`, { params: { start, end } });
return data;
},
// Productos
async getProducts(): Promise<{ status: string; products: Product[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/products`);
return data;
},
// Monederos
async getWallets(params?: string | { phone?: string; search?: string; page?: number; page_size?: number }): Promise<{ status: string; wallets: Wallet[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const p = typeof params === 'string' ? { phone: params } : params || {};
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wallets`, { params: p });
return data;
},
async getWalletByPhone(phone: string): Promise<{ status: string; wallet: { points: number; equivalent_mxn: number; history: unknown[] } }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wallets/${phone}`);
return data;
},
async walletTransaction(payload: { phone: string; type: 'accrual' | 'redemption'; points: number; description?: string }): Promise<{ status: string; wallet: Wallet }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wallets/transaction`, payload);
return data;
},
// Pagos
async getPayments(state?: string): Promise<{ status: string; payments: Payment[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/payments`, { params: state ? { state } : {} });
return data;
},
async createPayment(payment: Partial<Payment>): Promise<{ status: string; payment: Payment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/payments`, payment);
return data;
},
async confirmPayment(id: number): Promise<{ status: string; payment: Payment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/payments/${id}/confirm`);
return data;
},
// Ventas
async getSales(params?: Record<string, string | number>): Promise<{ status: string; sales: Sale[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales`, { params });
return data;
},
async createSale(sale: Omit<Partial<Sale>, 'lines'> & { lines: Omit<SaleLine, 'subtotal'>[] }): Promise<{ status: string; sale: Sale }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales`, sale);
return data;
},
async paySale(id: number, payload: { payment_method: string; amount?: number }): Promise<{ status: string; sale: Sale; payment: Payment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}/pay`, payload);
return data;
},
async refundSale(id: number, payload: { amount?: number; reason?: string }): Promise<{ status: string; sale: Sale; result: { refund_amount: number; points_reversed: number; new_state: string; new_amount_paid: number } }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}/refund`, payload);
return data;
},
// Cortes de caja
async getCashClosings(date?: string): Promise<{ status: string; cash_closings: CashClosing[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/cash_closings`, { params: date ? { date } : {} });
return data;
},
async createCashClosing(payload: { date?: string; opening_cash: number; notes?: string }): Promise<{ status: string; cash_closing: CashClosing }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cash_closings`, payload);
return data;
},
async closeCashClosing(id: number, closing_cash: number): Promise<{ status: string; cash_closing: CashClosing }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cash_closings/${id}/close`, { closing_cash });
return data;
},
// Reportes
async getSalesReport(start?: string, end?: string): Promise<{ status: string; total_sales: number; total_paid: number; total_due: number; count: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/sales`, { params: { start, end } });
return data;
},
async getAppointmentsReport(start?: string, end?: string): Promise<{ status: string; total: number; by_state: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/appointments`, { params: { start, end } });
return data;
},
async getTopServices(start?: string, end?: string, limit = 8): Promise<{ status: string; start: string; end: string; services: { service_id: number; service: string; qty: number; revenue: number }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/top_services`, { params: { start, end, limit } });
return data;
},
async getCashReport(date?: string): Promise<{ status: string; total: number; by_method: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/cash`, { params: { date } });
return data;
},
// Tipo de cambio
async getExchangeRate(): Promise<{ status: string; current: { rate: number; date: string; source: string } | null; history: { id: number; date: string; rate: number; source: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/exchange_rate`);
return data;
},
async setExchangeRate(rate: number, source = 'manual'): Promise<{ status: string; exchange_rate: { id: number; date: string; rate: number; source: string } }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/exchange_rate`, { rate, source });
return data;
},
// Meta de ventas mensual
async getSalesGoal(): Promise<{ status: string; goal: number; current: number; pct: number; start: string; end: string; count: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales_goal`);
return data;
},
async setSalesGoal(goal: number): Promise<{ status: string; goal: number }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales_goal`, { goal });
return data;
},
// Cumpleañeros
async getBirthdays(period: 'today' | 'week' | 'month' = 'month'): Promise<{ status: string; period: string; start: string; end: string; total: number; birthdays: Birthday[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/birthdays`, { params: { period } });
return data;
},
// Inventario / Consumibles
async getInventory(params?: { kind?: InventoryKind; level?: InventoryLevel; search?: string }): Promise<{ status: string; items: InventoryItem[]; summary: InventorySummary }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventory/items`, { params });
return data;
},
async createInventoryItem(item: Partial<InventoryItem>): Promise<{ status: string; item: InventoryItem }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/inventory/items`, item);
return data;
},
async updateInventoryItem(id: number, item: Partial<InventoryItem>): Promise<{ status: string; item: InventoryItem }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/inventory/items/${id}`, item);
return data;
},
async getInventoryMoves(id: number): Promise<{ status: string; moves: InventoryMove[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventory/items/${id}/moves`);
return data;
},
async createInventoryMove(id: number, payload: { type: InventoryMoveType; qty: number; reference?: string; notes?: string }): Promise<{ status: string; move: InventoryMove; item: InventoryItem }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/inventory/items/${id}/moves`, payload);
return data;
},
// ============================================================
// WACRM Proxy
// ============================================================
async syncWacrm(): Promise<{ status: string; result: Record<string, number | string | null> }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/sync`);
return data;
},
async getWacrmMembers(): Promise<{ status: string; members: { id: string; name: string; email?: string; avatar_url?: string; role: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/members`);
return data;
},
async assignWacrmConversation(external_id: string, assigned_agent_id: string | null): Promise<{ status: string; assigned_agent_id?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations/${external_id}/assign`, {
assigned_agent_id,
});
return data;
},
async getWacrmConversations(search?: string): Promise<{ status: string; conversations: WacrmConversation[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations`, { params: search ? { search } : {} });
return data;
},
async sendWacrmMessage(payload: { conversation_id: string; text: string; assigned_agent_id: string }): Promise<{ status: string; message: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/messages`, payload);
return data;
},
async getWacrmMessages(search?: string, conversation_id?: string): Promise<{ status: string; messages: WacrmMessage[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/messages`, {
params: { ...(search ? { search } : {}), ...(conversation_id ? { conversation_id } : {}) },
});
return data;
},
async getWacrmPipelines(): Promise<{ status: string; pipelines: WacrmPipeline[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/pipelines`);
return data;
},
async getWacrmLeads(search?: string, status?: string, pipeline_id?: string): Promise<{ status: string; leads: WacrmLead[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/leads`, {
params: { ...(search ? { search } : {}), ...(status ? { status } : {}), ...(pipeline_id ? { pipeline_id } : {}) },
});
return data;
},
async updateWacrmLeadStatus(id: number, payload: { status?: 'open' | 'won' | 'lost'; stage_id?: string }): Promise<{ status: string; lead: WacrmLead }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/wacrm/leads/${id}/status`, payload);
return data;
},
};

View File

@@ -0,0 +1,101 @@
/* Fuentes locales de la marca SKEEN + Google Fonts para HomeNest */
/* Sans Essential — cuerpo y UI (SKEEN) */
@font-face {
font-family: 'Sans Essential';
src: url('/skeen-brand/tipografias/Sans-Essential-thin.otf') format('opentype');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sans Essential';
src: url('/skeen-brand/tipografias/Sans-Essential-book.otf') format('opentype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sans Essential';
src: url('/skeen-brand/tipografias/Sans-Essential-regular.otf') format('opentype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sans Essential';
src: url('/skeen-brand/tipografias/Sans-Essential-bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Carefree Serif — títulos de display (SKEEN) */
@font-face {
font-family: 'Carefree Serif';
src: url('/skeen-brand/tipografias/CarefreeSerif-Thin.otf') format('opentype');
font-weight: 100;
font-style: normal;
font-display: swap;
}
/* TT Hoves Pro — alternativa para títulos y logo-texto (SKEEN) */
@font-face {
font-family: 'TT Hoves Pro';
src: url('/skeen-brand/tipografias/TT%20Hoves%20Pro/TT%20Hoves%20Pro%20Trial%20Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'TT Hoves Pro';
src: url('/skeen-brand/tipografias/TT%20Hoves%20Pro/TT%20Hoves%20Pro%20Trial%20Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'TT Hoves Pro';
src: url('/skeen-brand/tipografias/TT%20Hoves%20Pro/TT%20Hoves%20Pro%20Trial%20Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'TT Hoves Pro';
src: url('/skeen-brand/tipografias/TT%20Hoves%20Pro/TT%20Hoves%20Pro%20Trial%20Black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}
/* Google Fonts para HomeNest (cargadas también en index.html) */
@font-face {
font-family: 'Alike';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('https://fonts.gstatic.com/s/alike/v20/HIqhi7Y-DAyCmA5nqQ0ubw.woff2') format('woff2');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 100 1000;
font-display: swap;
src: url('https://fonts.gstatic.com/s/dmsans/v15/rP2Yp2ywxg089UriI5-g4vlHRe3PeY0OevCw-Ob1.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPVmUsaaDhw.woff2') format('woff2');
}

View File

@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
brand: {
coral: 'var(--color-coral)',
mint: 'var(--color-mint)',
lilac: 'var(--color-lilac)',
sky: 'var(--color-sky)',
dark: 'var(--color-dark)',
charcoal: 'var(--color-charcoal)',
gray: 'var(--color-gray)',
light: 'var(--color-light)',
white: 'var(--color-white)',
},
theme: {
text: 'var(--text)',
muted: 'var(--text-muted)',
heading: 'var(--text-heading)',
inverse: 'var(--text-inverse)',
bg: 'var(--bg)',
surface: 'var(--surface)',
elevated: 'var(--surface-elevated)',
border: 'var(--border)',
'border-strong': 'var(--border-strong)',
accent: 'var(--accent)',
'accent-hover': 'var(--accent-hover)',
'accent-bg': 'var(--accent-bg)',
success: 'var(--success)',
'success-text': 'var(--success-text)',
warning: 'var(--warning)',
'warning-text': 'var(--warning-text)',
},
},
fontFamily: {
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
display: ['var(--font-display)', 'Georgia', 'serif'],
heading: ['var(--font-heading)', 'system-ui', 'sans-serif'],
mono: ['var(--font-mono)', 'monospace'],
},
borderRadius: {
'2xl': '1rem',
'3xl': '1.5rem',
},
boxShadow: {
soft: '0 4px 20px -4px rgba(62, 44, 28, 0.08)',
card: '0 2px 12px -2px rgba(62, 44, 28, 0.06)',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

27
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api/odoo': {
target: 'http://localhost:8069',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/odoo/, ''),
},
'/api/wacrm': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/wacrm/, '/api/v1'),
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
})