Compare commits
3 Commits
c254733940
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be48853e0e | ||
|
|
2f70cc687a | ||
|
|
b64813ebf7 |
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,
|
||||
},
|
||||
}));
|
||||
@@ -49,6 +49,7 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.POST, "/api/usuarios/registrar").permitAll()
|
||||
.requestMatchers("/api/usuarios/**").permitAll()
|
||||
.requestMatchers("/favicon.ico", "/error", "/static/**", "/contador/**").permitAll()
|
||||
.requestMatchers("/panel", "/panel/**").permitAll()
|
||||
.requestMatchers("/admin/**").hasAuthority("ADMIN")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.example.fercoganbackend.configuration;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Configuration
|
||||
public class SpaPanelConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/panel/**")
|
||||
.addResourceLocations("classpath:/static/panel/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String resourcePath, Resource location) throws IOException {
|
||||
Resource requested = location.createRelative(resourcePath);
|
||||
if (requested.exists() && requested.isReadable()) {
|
||||
return requested;
|
||||
}
|
||||
return new ClassPathResource("/static/panel/index.html");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.fercoganbackend.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.persistence.*;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -10,6 +11,7 @@ public class Usuario {
|
||||
private Long id;
|
||||
|
||||
private String username;
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false)
|
||||
|
||||
@@ -38,13 +38,17 @@ public class UsuarioDetailsService implements UserDetailsService {
|
||||
logger.info("Usuario encontrado: {}, aprobado: {}", usuario.getUsername(), usuario.isAprobado());
|
||||
logger.info("Roles del usuario: {}", usuario.getRoles());
|
||||
|
||||
var authorities = usuario.getRoles() == null
|
||||
? List.<SimpleGrantedAuthority>of()
|
||||
: usuario.getRoles().stream()
|
||||
.map(r -> new SimpleGrantedAuthority(r.name()))
|
||||
.toList();
|
||||
|
||||
return new org.springframework.security.core.userdetails.User(
|
||||
usuario.getUsername(),
|
||||
usuario.getPassword(),
|
||||
usuario.isAprobado(), true, true, true,
|
||||
usuario.getRoles().stream()
|
||||
.map(r -> new SimpleGrantedAuthority(r.name()))
|
||||
.toList()
|
||||
authorities
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
0
src/main/resources/static/panel/.gitkeep
Normal file
0
src/main/resources/static/panel/.gitkeep
Normal file
Reference in New Issue
Block a user