se implemento un frotend para el panel administrador
This commit is contained in:
5
admin-panel/.env.example
Normal file
5
admin-panel/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# URL del backend (vacío = mismo origen o proxy de Vite en desarrollo)
|
||||||
|
VITE_API_URL=
|
||||||
|
|
||||||
|
# Producción remota, ejemplo:
|
||||||
|
# VITE_API_URL=https://testapp.digitaltelecom.net
|
||||||
5
admin-panel/.gitignore
vendored
Normal file
5
admin-panel/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
53
admin-panel/README.md
Normal file
53
admin-panel/README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Ferco Admin Panel
|
||||||
|
|
||||||
|
Panel web para gestionar el backend (usuarios, remates, lotes, cabañas, pujas) con autenticación JWT.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Backend Spring Boot en ejecución (puerto 8080 por defecto)
|
||||||
|
|
||||||
|
## Desarrollo (recomendado)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin-panel
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Abre http://localhost:5173 — las peticiones `/api` se redirigen al backend vía proxy de Vite.
|
||||||
|
|
||||||
|
Para apuntar a un servidor remoto, crea `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_API_URL=https://testapp.digitaltelecom.net
|
||||||
|
```
|
||||||
|
|
||||||
|
## Producción (embebido en Spring Boot)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin-panel
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto genera los archivos en `src/main/resources/static/panel/`. Reinicia el backend y abre:
|
||||||
|
|
||||||
|
**http://localhost:8080/panel/**
|
||||||
|
|
||||||
|
(o `https://tu-dominio/panel/`)
|
||||||
|
|
||||||
|
## Inicio de sesión
|
||||||
|
|
||||||
|
Usa un usuario **ya registrado y aprobado** (`aprobado = true`) del backend. El login llama a `POST /api/usuarios/login` y guarda el JWT en el navegador.
|
||||||
|
|
||||||
|
## Módulos
|
||||||
|
|
||||||
|
| Sección | Funciones |
|
||||||
|
|-----------|------------------------------------------------|
|
||||||
|
| Dashboard | Resumen de contadores |
|
||||||
|
| Usuarios | Listar, aprobar pendientes, editar, eliminar |
|
||||||
|
| Remates | CRUD |
|
||||||
|
| Lotes | CRUD |
|
||||||
|
| Cabañas | CRUD |
|
||||||
|
| Pujas | Listar y eliminar |
|
||||||
15
admin-panel/index.html
Normal file
15
admin-panel/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ferco Admin</title>
|
||||||
|
<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=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1878
admin-panel/package-lock.json
generated
Normal file
1878
admin-panel/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
admin-panel/package.json
Normal file
20
admin-panel/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "ferco-admin-panel",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
admin-panel/src/App.jsx
Normal file
41
admin-panel/src/App.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { useAuth } from './context/AuthContext';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Users from './pages/Users';
|
||||||
|
import Remates from './pages/Remates';
|
||||||
|
import Lotes from './pages/Lotes';
|
||||||
|
import Cabanas from './pages/Cabanas';
|
||||||
|
import Pujas from './pages/Pujas';
|
||||||
|
|
||||||
|
function PrivateRoute({ children }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
if (loading) return <div className="page-loading">Cargando…</div>;
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Layout />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="usuarios" element={<Users />} />
|
||||||
|
<Route path="remates" element={<Remates />} />
|
||||||
|
<Route path="lotes" element={<Lotes />} />
|
||||||
|
<Route path="cabanas" element={<Cabanas />} />
|
||||||
|
<Route path="pujas" element={<Pujas />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
admin-panel/src/api/client.js
Normal file
103
admin-panel/src/api/client.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem('authToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('usuario');
|
||||||
|
localStorage.removeItem('rolId');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api(endpoint, options = {}) {
|
||||||
|
const token = getToken();
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${API_BASE}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
|
||||||
|
const res = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearSession();
|
||||||
|
if (!window.location.pathname.endsWith('/login')) {
|
||||||
|
window.location.href = `${import.meta.env.BASE_URL}login`.replace('//', '/');
|
||||||
|
}
|
||||||
|
throw new Error('Sesión expirada o no autorizado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data?.error || data?.message || `Error ${res.status}`;
|
||||||
|
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (username, password) =>
|
||||||
|
api('/api/usuarios/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
}),
|
||||||
|
me: () => api('/api/usuarios/me'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usuariosApi = {
|
||||||
|
list: () => api('/api/usuarios'),
|
||||||
|
pendientes: () => api('/api/usuarios/pendientes'),
|
||||||
|
aceptar: (id) => api(`/api/usuarios/aceptar/${id}`, { method: 'PATCH' }),
|
||||||
|
update: (id, body) => api(`/api/usuarios/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
remove: (id) => api(`/api/usuarios/${id}`, { method: 'DELETE' }),
|
||||||
|
registrar: (body) => api('/api/usuarios/registrar', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rematesApi = {
|
||||||
|
list: () => api('/api/remates'),
|
||||||
|
get: (id) => api(`/api/remates/${id}`),
|
||||||
|
create: (body) => api('/api/remates', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
update: (id, body) => api(`/api/remates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
remove: (id) => api(`/api/remates/${id}`, { method: 'DELETE' }),
|
||||||
|
finalizar: (remateId, loteId) =>
|
||||||
|
api(`/api/remates/${remateId}/finalizar/${loteId}`, { method: 'PUT' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lotesApi = {
|
||||||
|
list: () => api('/api/lotes'),
|
||||||
|
get: (id) => api(`/api/lotes/${id}`),
|
||||||
|
create: (body) => api('/api/lotes', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
update: (id, body) => api(`/api/lotes/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
remove: (id) => api(`/api/lotes/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cabanasApi = {
|
||||||
|
list: () => api('/api/cabanas'),
|
||||||
|
get: (id) => api(`/api/cabanas/${id}`),
|
||||||
|
create: (body) => api('/api/cabanas', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
update: (id, body) => api(`/api/cabanas/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
remove: (id) => api(`/api/cabanas/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pujasApi = {
|
||||||
|
list: () => api('/api/pujas'),
|
||||||
|
remove: (id) => api(`/api/pujas/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rolesApi = {
|
||||||
|
list: () => api('/api/roles'),
|
||||||
|
};
|
||||||
50
admin-panel/src/components/Layout.jsx
Normal file
50
admin-panel/src/components/Layout.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ to: '/', label: 'Dashboard', end: true },
|
||||||
|
{ to: '/usuarios', label: 'Usuarios' },
|
||||||
|
{ to: '/remates', label: 'Remates' },
|
||||||
|
{ to: '/lotes', label: 'Lotes' },
|
||||||
|
{ to: '/cabanas', label: 'Cabañas' },
|
||||||
|
{ to: '/pujas', label: 'Pujas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-layout">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-brand">Ferco Admin</div>
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{nav.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) => (isActive ? 'active' : '')}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<div>{user?.username}</div>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" style={{ marginTop: '0.5rem' }} onClick={handleLogout}>
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="main-content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
admin-panel/src/components/Modal.jsx
Normal file
20
admin-panel/src/components/Modal.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export default function Modal({ title, children, onClose, onSave, saveLabel = 'Guardar', saving }) {
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
{onSave && (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={onSave} disabled={saving}>
|
||||||
|
{saving ? 'Guardando…' : saveLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
admin-panel/src/context/AuthContext.jsx
Normal file
51
admin-panel/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { authApi, clearSession, getToken } from '../api/client';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authApi
|
||||||
|
.me()
|
||||||
|
.then((data) => setUser(data))
|
||||||
|
.catch(() => clearSession())
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username, password) => {
|
||||||
|
const data = await authApi.login(username, password);
|
||||||
|
localStorage.setItem('authToken', data.token);
|
||||||
|
localStorage.setItem('usuario', data.username);
|
||||||
|
localStorage.setItem('rolId', String(data.rolId ?? ''));
|
||||||
|
setUser({
|
||||||
|
username: data.username,
|
||||||
|
userId: data.userId,
|
||||||
|
rolId: data.rolId,
|
||||||
|
confirmed: data.confirmed,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
clearSession();
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout, isAuthenticated: !!getToken() && !!user }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
16
admin-panel/src/main.jsx
Normal file
16
admin-panel/src/main.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter basename={import.meta.env.BASE_URL.replace(/\/$/, '') || '/'}>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
147
admin-panel/src/pages/Cabanas.jsx
Normal file
147
admin-panel/src/pages/Cabanas.jsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
import { cabanasApi } from '../api/client';
|
||||||
|
|
||||||
|
const emptyForm = () => ({ nombre: '', telefono: '', visible: true });
|
||||||
|
|
||||||
|
export default function Cabanas() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [modal, setModal] = useState(null);
|
||||||
|
const [form, setForm] = useState(emptyForm());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
cabanasApi
|
||||||
|
.list()
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const guardar = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
const body = {
|
||||||
|
nombre: form.nombre,
|
||||||
|
telefono: form.telefono ? Number(form.telefono) : null,
|
||||||
|
visible: form.visible,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (modal === 'create') await cabanasApi.create(body);
|
||||||
|
else await cabanasApi.update(modal.id, body);
|
||||||
|
setModal(null);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const eliminar = async (id) => {
|
||||||
|
if (!confirm('¿Eliminar cabaña?')) return;
|
||||||
|
try {
|
||||||
|
await cabanasApi.remove(id);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Cabañas</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setForm(emptyForm());
|
||||||
|
setModal('create');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Nueva cabaña
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Teléfono</th>
|
||||||
|
<th>Visible</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.id}</td>
|
||||||
|
<td>{c.nombre}</td>
|
||||||
|
<td>{c.telefono}</td>
|
||||||
|
<td>{c.visible !== false ? 'Sí' : 'No'}</td>
|
||||||
|
<td>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setForm({
|
||||||
|
nombre: c.nombre || '',
|
||||||
|
telefono: c.telefono ?? '',
|
||||||
|
visible: c.visible !== false,
|
||||||
|
});
|
||||||
|
setModal({ type: 'edit', id: c.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => eliminar(c.id)}>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<Modal
|
||||||
|
title={modal === 'create' ? 'Nueva cabaña' : 'Editar cabaña'}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
onSave={guardar}
|
||||||
|
saving={saving}
|
||||||
|
>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input value={form.nombre} onChange={(e) => setForm({ ...form, nombre: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Teléfono</label>
|
||||||
|
<input value={form.telefono} onChange={(e) => setForm({ ...form, telefono: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.visible}
|
||||||
|
onChange={(e) => setForm({ ...form, visible: e.target.checked })}
|
||||||
|
/>{' '}
|
||||||
|
Visible
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
admin-panel/src/pages/Dashboard.jsx
Normal file
63
admin-panel/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { cabanasApi, lotesApi, pujasApi, rematesApi, usuariosApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
usuariosApi.list(),
|
||||||
|
usuariosApi.pendientes(),
|
||||||
|
rematesApi.list(),
|
||||||
|
lotesApi.list(),
|
||||||
|
cabanasApi.list(),
|
||||||
|
pujasApi.list(),
|
||||||
|
])
|
||||||
|
.then(([usuarios, pendientes, remates, lotes, cabanas, pujas]) => {
|
||||||
|
setStats({
|
||||||
|
usuarios: usuarios.length,
|
||||||
|
pendientes: pendientes.length,
|
||||||
|
remates: remates.length,
|
||||||
|
lotes: lotes.length,
|
||||||
|
cabanas: cabanas.length,
|
||||||
|
pujas: pujas.length,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) return <div className="alert alert-error">{error}</div>;
|
||||||
|
if (!stats) return <div className="page-loading">Cargando estadísticas…</div>;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: 'Usuarios', value: stats.usuarios, to: '/usuarios' },
|
||||||
|
{ label: 'Pendientes aprobación', value: stats.pendientes, to: '/usuarios', warn: stats.pendientes > 0 },
|
||||||
|
{ label: 'Remates', value: stats.remates, to: '/remates' },
|
||||||
|
{ label: 'Lotes', value: stats.lotes, to: '/lotes' },
|
||||||
|
{ label: 'Cabañas', value: stats.cabanas, to: '/cabanas' },
|
||||||
|
{ label: 'Pujas', value: stats.pujas, to: '/pujas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<div className="stats-grid">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Link key={c.label} to={c.to} className="stat-card" style={{ textDecoration: 'none' }}>
|
||||||
|
<div className="label">{c.label}</div>
|
||||||
|
<div className="value" style={c.warn ? { color: 'var(--warning)' } : undefined}>
|
||||||
|
{c.value}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||||
|
Usa el menú lateral para gestionar usuarios, remates, lotes, cabañas y pujas del backend.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
admin-panel/src/pages/Login.jsx
Normal file
65
admin-panel/src/pages/Login.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login, isAuthenticated, loading } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
if (!loading && isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(username.trim(), password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Error al iniciar sesión');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>Panel de administración</h1>
|
||||||
|
<p>Inicia sesión con un usuario aprobado del backend.</p>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Email / usuario</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="email"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Contraseña</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={submitting}>
|
||||||
|
{submitting ? 'Entrando…' : 'Iniciar sesión'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
admin-panel/src/pages/Lotes.jsx
Normal file
243
admin-panel/src/pages/Lotes.jsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
import { cabanasApi, lotesApi, rematesApi } from '../api/client';
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
nombre: '',
|
||||||
|
precio: '',
|
||||||
|
raza: '',
|
||||||
|
prelance: '',
|
||||||
|
puja: '',
|
||||||
|
estado: '',
|
||||||
|
video: '',
|
||||||
|
numLote: 0,
|
||||||
|
visible: true,
|
||||||
|
remateId: '',
|
||||||
|
cabanaId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function toPayload(form) {
|
||||||
|
const body = {
|
||||||
|
nombre: form.nombre,
|
||||||
|
raza: form.raza || null,
|
||||||
|
estado: form.estado || null,
|
||||||
|
video: form.video || null,
|
||||||
|
numLote: Number(form.numLote) || 0,
|
||||||
|
visible: form.visible,
|
||||||
|
};
|
||||||
|
if (form.precio !== '') body.precio = Number(form.precio);
|
||||||
|
if (form.prelance !== '') body.prelance = Number(form.prelance);
|
||||||
|
if (form.puja !== '') body.puja = Number(form.puja);
|
||||||
|
if (form.remateId) body.remate = { id: Number(form.remateId) };
|
||||||
|
if (form.cabanaId) body.cabana = { id: Number(form.cabanaId) };
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Lotes() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [remates, setRemates] = useState([]);
|
||||||
|
const [cabanas, setCabanas] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [modal, setModal] = useState(null);
|
||||||
|
const [form, setForm] = useState(emptyForm());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
Promise.all([lotesApi.list(), rematesApi.list(), cabanasApi.list()])
|
||||||
|
.then(([l, r, c]) => {
|
||||||
|
setItems(l);
|
||||||
|
setRemates(r);
|
||||||
|
setCabanas(c);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setForm(emptyForm());
|
||||||
|
setModal('create');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (l) => {
|
||||||
|
setForm({
|
||||||
|
nombre: l.nombre || '',
|
||||||
|
precio: l.precio ?? '',
|
||||||
|
raza: l.raza || '',
|
||||||
|
prelance: l.prelance ?? '',
|
||||||
|
puja: l.puja ?? '',
|
||||||
|
estado: l.estado || '',
|
||||||
|
video: l.video || '',
|
||||||
|
numLote: l.numLote ?? 0,
|
||||||
|
visible: l.visible !== false,
|
||||||
|
remateId: l.remate?.id ?? '',
|
||||||
|
cabanaId: l.cabana?.id ?? '',
|
||||||
|
});
|
||||||
|
setModal({ type: 'edit', id: l.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardar = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload = toPayload(form);
|
||||||
|
if (modal === 'create') await lotesApi.create(payload);
|
||||||
|
else await lotesApi.update(modal.id, payload);
|
||||||
|
setModal(null);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const eliminar = async (id) => {
|
||||||
|
if (!confirm('¿Eliminar lote?')) return;
|
||||||
|
try {
|
||||||
|
await lotesApi.remove(id);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Lotes</h1>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||||||
|
+ Nuevo lote
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nº</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Precio</th>
|
||||||
|
<th>Remate</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>{l.id}</td>
|
||||||
|
<td>{l.numLote}</td>
|
||||||
|
<td>{l.nombre}</td>
|
||||||
|
<td>{l.precio}</td>
|
||||||
|
<td>{l.remate?.nombre ?? l.remate?.id ?? '—'}</td>
|
||||||
|
<td>{l.estado}</td>
|
||||||
|
<td>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openEdit(l)}>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => eliminar(l.id)}>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<Modal
|
||||||
|
title={modal === 'create' ? 'Nuevo lote' : 'Editar lote'}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
onSave={guardar}
|
||||||
|
saving={saving}
|
||||||
|
>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input value={form.nombre} onChange={(e) => setForm({ ...form, nombre: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nº lote</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.numLote}
|
||||||
|
onChange={(e) => setForm({ ...form, numLote: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Precio</label>
|
||||||
|
<input value={form.precio} onChange={(e) => setForm({ ...form, precio: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Puja actual</label>
|
||||||
|
<input value={form.puja} onChange={(e) => setForm({ ...form, puja: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Raza</label>
|
||||||
|
<input value={form.raza} onChange={(e) => setForm({ ...form, raza: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Prelance</label>
|
||||||
|
<input value={form.prelance} onChange={(e) => setForm({ ...form, prelance: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Estado</label>
|
||||||
|
<input value={form.estado} onChange={(e) => setForm({ ...form, estado: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Video (URL)</label>
|
||||||
|
<input value={form.video} onChange={(e) => setForm({ ...form, video: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Remate</label>
|
||||||
|
<select value={form.remateId} onChange={(e) => setForm({ ...form, remateId: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{remates.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.nombre} (#{r.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Cabaña</label>
|
||||||
|
<select value={form.cabanaId} onChange={(e) => setForm({ ...form, cabanaId: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{cabanas.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.visible}
|
||||||
|
onChange={(e) => setForm({ ...form, visible: e.target.checked })}
|
||||||
|
/>{' '}
|
||||||
|
Visible
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
admin-panel/src/pages/Pujas.jsx
Normal file
72
admin-panel/src/pages/Pujas.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { pujasApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function Pujas() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
pujasApi
|
||||||
|
.list()
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const eliminar = async (id) => {
|
||||||
|
if (!confirm('¿Eliminar puja?')) return;
|
||||||
|
try {
|
||||||
|
await pujasApi.remove(id);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Pujas</h1>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Monto</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>Lote</th>
|
||||||
|
<th>Cabaña</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>{p.id}</td>
|
||||||
|
<td>{p.monto}</td>
|
||||||
|
<td>{p.fecha ? new Date(p.fecha).toLocaleString() : '—'}</td>
|
||||||
|
<td>{p.usuario?.username ?? p.usuario?.id ?? '—'}</td>
|
||||||
|
<td>{p.lote?.nombre ?? p.lote?.id ?? '—'}</td>
|
||||||
|
<td>{p.cabana?.nombre ?? p.cabana?.id ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => eliminar(p.id)}>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 && <div className="empty-state">No hay pujas</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
admin-panel/src/pages/Remates.jsx
Normal file
215
admin-panel/src/pages/Remates.jsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
import { cabanasApi, rematesApi } from '../api/client';
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
nombre: '',
|
||||||
|
estado: 'activo',
|
||||||
|
urlListaLotes: '',
|
||||||
|
fecha: '',
|
||||||
|
fechaFin: '',
|
||||||
|
visible: true,
|
||||||
|
cabanaId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function toLocalInput(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(form) {
|
||||||
|
const body = {
|
||||||
|
nombre: form.nombre,
|
||||||
|
estado: form.estado,
|
||||||
|
urlListaLotes: form.urlListaLotes || null,
|
||||||
|
visible: form.visible,
|
||||||
|
};
|
||||||
|
if (form.fecha) body.fecha = form.fecha;
|
||||||
|
if (form.fechaFin) body.fechaFin = form.fechaFin;
|
||||||
|
if (form.cabanaId) body.cabana = { id: Number(form.cabanaId) };
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Remates() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [cabanas, setCabanas] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [modal, setModal] = useState(null);
|
||||||
|
const [form, setForm] = useState(emptyForm());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
Promise.all([rematesApi.list(), cabanasApi.list()])
|
||||||
|
.then(([r, c]) => {
|
||||||
|
setItems(r);
|
||||||
|
setCabanas(c);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setForm(emptyForm());
|
||||||
|
setModal('create');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (r) => {
|
||||||
|
setForm({
|
||||||
|
nombre: r.nombre || '',
|
||||||
|
estado: r.estado || '',
|
||||||
|
urlListaLotes: r.urlListaLotes || '',
|
||||||
|
fecha: toLocalInput(r.fecha),
|
||||||
|
fechaFin: toLocalInput(r.fechaFin),
|
||||||
|
visible: r.visible !== false,
|
||||||
|
cabanaId: r.cabana?.id ?? '',
|
||||||
|
});
|
||||||
|
setModal({ type: 'edit', id: r.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardar = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
const payload = toPayload(form);
|
||||||
|
try {
|
||||||
|
if (modal === 'create') {
|
||||||
|
await rematesApi.create(payload);
|
||||||
|
} else {
|
||||||
|
await rematesApi.update(modal.id, payload);
|
||||||
|
}
|
||||||
|
setModal(null);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const eliminar = async (id) => {
|
||||||
|
if (!confirm('¿Eliminar remate?')) return;
|
||||||
|
try {
|
||||||
|
await rematesApi.remove(id);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Remates</h1>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||||||
|
+ Nuevo remate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Cabaña</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>{r.id}</td>
|
||||||
|
<td>{r.nombre}</td>
|
||||||
|
<td>{r.estado}</td>
|
||||||
|
<td>{r.cabana?.nombre ?? r.cabana?.id ?? '—'}</td>
|
||||||
|
<td>{r.fecha ? new Date(r.fecha).toLocaleString() : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openEdit(r)}>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => eliminar(r.id)}>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<Modal
|
||||||
|
title={modal === 'create' ? 'Nuevo remate' : 'Editar remate'}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
onSave={guardar}
|
||||||
|
saving={saving}
|
||||||
|
>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input value={form.nombre} onChange={(e) => setForm({ ...form, nombre: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Estado</label>
|
||||||
|
<input value={form.estado} onChange={(e) => setForm({ ...form, estado: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Cabaña</label>
|
||||||
|
<select value={form.cabanaId} onChange={(e) => setForm({ ...form, cabanaId: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{cabanas.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>URL lista lotes</label>
|
||||||
|
<input value={form.urlListaLotes} onChange={(e) => setForm({ ...form, urlListaLotes: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Fecha inicio</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.fecha}
|
||||||
|
onChange={(e) => setForm({ ...form, fecha: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Fecha fin</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.fechaFin}
|
||||||
|
onChange={(e) => setForm({ ...form, fechaFin: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.visible}
|
||||||
|
onChange={(e) => setForm({ ...form, visible: e.target.checked })}
|
||||||
|
/>{' '}
|
||||||
|
Visible
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
admin-panel/src/pages/Users.jsx
Normal file
249
admin-panel/src/pages/Users.jsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
import { rolesApi, usuariosApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const [usuarios, setUsuarios] = useState([]);
|
||||||
|
const [pendientes, setPendientes] = useState([]);
|
||||||
|
const [roles, setRoles] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [modal, setModal] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
Promise.all([usuariosApi.list(), usuariosApi.pendientes(), rolesApi.list()])
|
||||||
|
.then(([u, p, r]) => {
|
||||||
|
setUsuarios(u);
|
||||||
|
setPendientes(p);
|
||||||
|
setRoles(r);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const aprobar = async (id) => {
|
||||||
|
try {
|
||||||
|
await usuariosApi.aceptar(id);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const eliminar = async (id) => {
|
||||||
|
if (!confirm('¿Eliminar este usuario?')) return;
|
||||||
|
try {
|
||||||
|
await usuariosApi.remove(id);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardar = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await usuariosApi.update(modal.id, {
|
||||||
|
username: modal.username,
|
||||||
|
nombre: modal.nombre,
|
||||||
|
celular: modal.celular ? Number(modal.celular) : null,
|
||||||
|
ci: modal.ci ? Number(modal.ci) : null,
|
||||||
|
aprobado: modal.aprobado,
|
||||||
|
visible: modal.visible,
|
||||||
|
rol: modal.rolId ? Number(modal.rolId) : null,
|
||||||
|
password: modal.password || undefined,
|
||||||
|
});
|
||||||
|
setModal(null);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Usuarios</h1>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
{pendientes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className="section-title">Pendientes de aprobación ({pendientes.length})</h2>
|
||||||
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>CI</th>
|
||||||
|
<th>Celular</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pendientes.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>{u.id}</td>
|
||||||
|
<td>{u.username}</td>
|
||||||
|
<td>{u.nombre}</td>
|
||||||
|
<td>{u.ci}</td>
|
||||||
|
<td>{u.celular}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="btn btn-success btn-sm" onClick={() => aprobar(u.id)}>
|
||||||
|
Aprobar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="section-title">Todos los usuarios</h2>
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Rol</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Visible</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{usuarios.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>{u.id}</td>
|
||||||
|
<td>{u.username}</td>
|
||||||
|
<td>{u.nombre}</td>
|
||||||
|
<td>{u.rol?.name ?? u.rol?.id ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
{u.aprobado ? (
|
||||||
|
<span className="badge badge-success">Aprobado</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-warning">Pendiente</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{u.visible !== false ? 'Sí' : 'No'}</td>
|
||||||
|
<td>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
setModal({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
nombre: u.nombre || '',
|
||||||
|
celular: u.celular ?? '',
|
||||||
|
ci: u.ci ?? '',
|
||||||
|
aprobado: u.aprobado,
|
||||||
|
visible: u.visible !== false,
|
||||||
|
rolId: u.rol?.id ?? '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
{!u.aprobado && (
|
||||||
|
<button type="button" className="btn btn-success btn-sm" onClick={() => aprobar(u.id)}>
|
||||||
|
Aprobar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => eliminar(u.id)}>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{usuarios.length === 0 && <div className="empty-state">No hay usuarios</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<Modal title="Editar usuario" onClose={() => setModal(null)} onSave={guardar} saving={saving}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input value={modal.username} onChange={(e) => setModal({ ...modal, username: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input value={modal.nombre} onChange={(e) => setModal({ ...modal, nombre: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>CI</label>
|
||||||
|
<input value={modal.ci} onChange={(e) => setModal({ ...modal, ci: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Celular</label>
|
||||||
|
<input value={modal.celular} onChange={(e) => setModal({ ...modal, celular: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Rol (ID tabla roles)</label>
|
||||||
|
<select value={modal.rolId} onChange={(e) => setModal({ ...modal, rolId: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<option key={r.id ?? r.Id} value={r.id ?? r.Id}>
|
||||||
|
{r.name} (id: {r.id ?? r.Id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={modal.aprobado}
|
||||||
|
onChange={(e) => setModal({ ...modal, aprobado: e.target.checked })}
|
||||||
|
/>{' '}
|
||||||
|
Aprobado
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={modal.visible}
|
||||||
|
onChange={(e) => setModal({ ...modal, visible: e.target.checked })}
|
||||||
|
/>{' '}
|
||||||
|
Visible
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Nueva contraseña (opcional)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={modal.password}
|
||||||
|
onChange={(e) => setModal({ ...modal, password: e.target.value })}
|
||||||
|
placeholder="Dejar vacío para no cambiar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
413
admin-panel/src/styles/index.css
Normal file
413
admin-panel/src/styles/index.css
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1419;
|
||||||
|
--bg-card: #1a2332;
|
||||||
|
--bg-hover: #243044;
|
||||||
|
--border: #2d3a4f;
|
||||||
|
--text: #e8edf4;
|
||||||
|
--text-muted: #8b9cb3;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--radius: 10px;
|
||||||
|
--sidebar-w: 240px;
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a:hover,
|
||||||
|
.sidebar-nav a.active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: var(--success);
|
||||||
|
color: #052e16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-muted {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: radial-gradient(ellipse at top, #1e3a5f 0%, var(--bg) 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.4);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1.5rem 0 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
18
admin-panel/vite.config.js
Normal file
18
admin-panel/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig(({ command }) => ({
|
||||||
|
plugins: [react()],
|
||||||
|
base: command === 'build' ? '/panel/' : '/',
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
'/auth': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../src/main/resources/static/panel',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user