Compare commits

...

3 Commits

Author SHA1 Message Date
unknown
be48853e0e Integra panel admin en seguridad y corrige carga de usuarios con JWT2
Some checks failed
Deploy Spring Boot App / build-and-deploy (push) Has been cancelled
2026-05-21 15:28:50 -04:00
unknown
2f70cc687a Merge branch 'master' of https://gitea.digitaltelecom.net/DIGITAL_TELECOMUNICACIONES/TEST
Some checks failed
Deploy Spring Boot App / build-and-deploy (push) Has been cancelled
2026-05-21 15:23:12 -04:00
unknown
b64813ebf7 se implemento un frotend para el panel administrador 2026-05-21 15:13:00 -04:00
26 changed files with 3783 additions and 3 deletions

5
admin-panel/.env.example Normal file
View 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
View File

@@ -0,0 +1,5 @@
node_modules
dist
.env
.env.local
*.log

53
admin-panel/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

20
admin-panel/package.json Normal file
View 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
View 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>
);
}

View 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'),
};

View 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>
);
}

View 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>
);
}

View 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
View 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>
);

View 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>
)}
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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></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> 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>
)}
</>
);
}

View 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>
</>
);
}

View 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>
)}
</>
);
}

View 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>
)}
</>
);
}

View 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);
}

View 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,
},
}));

View File

@@ -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()
)

View File

@@ -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");
}
});
}
}

View File

@@ -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)

View File

@@ -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
);
}
}

View File