feat(docker): añadir Dockerfile para contenerizar la aplicación

This commit is contained in:
unknown
2026-06-11 16:05:10 -04:00
parent 74a67fbab2
commit 2a58693d31
70 changed files with 3826 additions and 874 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
dist
.git
.gitignore
.env
.env.*
!.env.example
*.md
!README.md
.vscode
.cursor
terminals
coverage
.DS_Store
*.log

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# URL base del backend Spring Boot (sin barra final).
# Vacío = mismo origen (útil con proxy de Vite en desarrollo).
VITE_API_URL=https://testapp.digitaltelecom.net

46
.gitignore vendored
View File

@@ -1,41 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. node_modules
dist
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store .DS_Store
*.pem *.log
.env
# debug .env.local
npm-debug.log* .env.*.local
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

145
.next/types/cache-life.d.ts vendored Normal file
View File

@@ -0,0 +1,145 @@
// Type definitions for Next.js cacheLife configs
declare module 'next/cache' {
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
export {
updateTag,
revalidateTag,
revalidatePath,
refresh,
} from 'next/dist/server/web/spec-extension/revalidate'
export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store'
/**
* Cache this `"use cache"` for a timespan defined by the `"default"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 900 seconds (15 minutes)
* expire: never
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 15 minutes, start revalidating new values in the background.
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
*/
export function cacheLife(profile: "default"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"seconds"` profile.
* ```
* stale: 30 seconds
* revalidate: 1 seconds
* expire: 60 seconds (1 minute)
* ```
*
* This cache may be stale on clients for 30 seconds before checking with the server.
* If the server receives a new request after 1 seconds, start revalidating new values in the background.
* If this entry has no traffic for 1 minute it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "seconds"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"minutes"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 60 seconds (1 minute)
* expire: 3600 seconds (1 hour)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 minute, start revalidating new values in the background.
* If this entry has no traffic for 1 hour it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "minutes"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"hours"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 3600 seconds (1 hour)
* expire: 86400 seconds (1 day)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 hour, start revalidating new values in the background.
* If this entry has no traffic for 1 day it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "hours"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"days"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 86400 seconds (1 day)
* expire: 604800 seconds (1 week)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 day, start revalidating new values in the background.
* If this entry has no traffic for 1 week it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "days"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"weeks"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 604800 seconds (1 week)
* expire: 2592000 seconds (1 month)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 week, start revalidating new values in the background.
* If this entry has no traffic for 1 month it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "weeks"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"max"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 2592000 seconds (1 month)
* expire: 31536000 seconds (365 days)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 month, start revalidating new values in the background.
* If this entry has no traffic for 365 days it will expire. The next request will recompute it.
*/
export function cacheLife(profile: "max"): void
/**
* Cache this `"use cache"` using a custom timespan.
* ```
* stale: ... // seconds
* revalidate: ... // seconds
* expire: ... // seconds
* ```
*
* This is similar to Cache-Control: max-age=`stale`,s-max-age=`revalidate`,stale-while-revalidate=`expire-revalidate`
*
* If a value is left out, the lowest of other cacheLife() calls or the default, is used instead.
*/
export function cacheLife(profile: {
/**
* This cache may be stale on clients for ... seconds before checking with the server.
*/
stale?: number,
/**
* If the server receives a new request after ... seconds, start revalidating new values in the background.
*/
revalidate?: number,
/**
* If this entry has no traffic for ... seconds it will expire. The next request will recompute it.
*/
expire?: number
}): void
import { cacheTag } from 'next/dist/server/use-cache/cache-tag'
export { cacheTag }
export const unstable_cacheTag: typeof cacheTag
export const unstable_cacheLife: typeof cacheLife
}

57
.next/types/routes.d.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
// This file is generated automatically by Next.js
// Do not edit this file manually
type AppRoutes = never
type PageRoutes = never
type LayoutRoutes = "/"
type RedirectRoutes = never
type RewriteRoutes = never
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes
interface ParamMap {
"/": {}
}
export type ParamsOf<Route extends Routes> = ParamMap[Route]
interface LayoutSlotMap {
"/": never
}
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap }
declare global {
/**
* Props for Next.js App Router page components
* @example
* ```tsx
* export default function Page(props: PageProps<'/blog/[slug]'>) {
* const { slug } = await props.params
* return <div>Blog post: {slug}</div>
* }
* ```
*/
interface PageProps<AppRoute extends AppRoutes> {
params: Promise<ParamMap[AppRoute]>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/**
* Props for Next.js App Router layout components
* @example
* ```tsx
* export default function Layout(props: LayoutProps<'/dashboard'>) {
* return <div>{props.children}</div>
* }
* ```
*/
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
params: Promise<ParamMap[LayoutRoute]>
children: React.ReactNode
} & {
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
}
}

16
.next/types/validator.ts Normal file
View File

@@ -0,0 +1,16 @@
// This file is generated automatically by Next.js
// Do not edit this file manually
// This file validates that all pages and layouts export the correct types

View File

@@ -1,5 +1,6 @@
<!-- BEGIN:nextjs-agent-rules --> # Ferco Admin (Vite + React 18)
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - Stack: React 18, Vite 6, TypeScript, Tailwind CSS 3, React Router 6, TanStack Query.
<!-- END:nextjs-agent-rules --> - Alias `@/``src/`.
- API base: `import.meta.env.VITE_API_URL` (vacío = mismo origen / proxy dev).
- Auth: JWT en `localStorage` key `ferco_admin_token`; cliente en `src/api/client.ts`.

View File

@@ -1 +0,0 @@
@AGENTS.md

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# --- Build ---
FROM oven/bun:1.3-alpine AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
ARG VITE_API_URL=https://testapp.digitaltelecom.net
ENV VITE_API_URL=${VITE_API_URL}
RUN bun run build
# --- Serve ---
FROM nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/ > /dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]

129
README.md
View File

@@ -1,36 +1,123 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). # Ferco Admin — Panel administrador de remates ganaderos
## Getting Started Frontend React 18 + Vite + TypeScript para gestionar el backend Spring Boot de remates en vivo. Consume la API REST, WebSocket nativo (`/ws/**`) y SSE (`/api/webflux/lotes/stream/*`).
First, run the development server: ## Requisitos
- [Bun](https://bun.sh) 1.x (o Node.js 18+ con `npm`)
## Instalación
Con Bun (recomendado en este repo):
```bash ```bash
npm run dev bun install
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Con npm:
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. ```bash
npm install
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## Variables de entorno
## Learn More Copie `.env.example` a `.env`:
To learn more about Next.js, take a look at the following resources: | Variable | Descripción |
|----------|-------------|
| `VITE_API_URL` | URL base del backend **sin** barra final. Ej: `https://testapp.digitaltelecom.net`. Si está **vacía**, las peticiones usan el mismo origen (recomendado en dev con el proxy de Vite). |
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ## Desarrollo
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ```bash
bun run dev
# o: npm run dev
```
## Deploy on Vercel Abre [http://localhost:5173](http://localhost:5173).
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Sin `VITE_API_URL`, Vite hace proxy de `/api`, `/auth`, `/contador` y `/ws` hacia `https://testapp.digitaltelecom.net` (configurable en `vite.config.ts`).
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ## Producción
```bash
bun run build
bun run preview
# o: npm run build && npm run preview
```
Despliegue: sirva la carpeta `dist/` en el mismo dominio que la API o configure CORS en el backend.
## Docker
Imagen multi-etapa: **Bun** compila el frontend y **nginx** sirve los estáticos.
```bash
# Con docker compose (recomendado)
docker compose up -d --build
# Panel en http://localhost:8080
```
Variables en `docker-compose.yml` o en un `.env` junto al compose:
| Variable | Default | Descripción |
|----------|---------|-------------|
| `VITE_API_URL` | `https://testapp.digitaltelecom.net` | URL del backend (se embebe en el build) |
| `APP_PORT` | `8080` | Puerto expuesto en el host |
Build manual:
```bash
docker build \
--build-arg VITE_API_URL=https://testapp.digitaltelecom.net \
-t ferco-admin:latest .
docker run -d -p 8080:80 --name ferco-admin ferco-admin:latest
```
> **Nota:** `VITE_API_URL` se define en **tiempo de build**. Si cambia la API, reconstruya la imagen (`docker compose up -d --build`).
## Autenticación
- Login: `POST /api/usuarios/login` (email + contraseña).
- Token JWT en `localStorage` (`ferco_admin_token`).
- Rutas del panel protegidas; `401` cierra sesión y redirige a `/login`.
- Solo usuarios **aprobados** (`confirmed: true`) pueden entrar.
## Módulos
| Ruta | Función |
|------|---------|
| `/` | Dashboard con totales y accesos rápidos |
| `/usuarios` | Listado, aprobación de pendientes, edición, eliminación |
| `/cabanas` | CRUD cabañas |
| `/remates` | CRUD remates, finalizar remate, indicador WS eventos |
| `/lotes` | CRUD lotes, suscripción SSE opcional por lote |
| `/pujas` | Listado, filtros, informes por remate |
| `/en-vivo` | Sala operador: contador WS/REST, reset, corregir, FIN_REMATE, pujas en vivo |
| `/historial` | `GET /api/HistorialPujas` |
## Tiempo real
- **Contador:** `wss://…/ws/puja/{remateId}/{loteId}` + REST `/contador/*`
- **Eventos remate:** `wss://…/ws/eventos/{remateId}` → mensaje `FIN_REMATE`
- **SSE lotes:** `GET /api/webflux/lotes/stream/{loteId}` con header `Authorization: Bearer …`
## Estructura
```
src/
api/ Cliente HTTP y endpoints
auth/ AuthContext, PrivateRoute
hooks/ useWebSocket, useContador, useEventosRemate, useLoteSSE
pages/ Pantallas del panel
components/ Layout, Sidebar, DataTable, Modal, etc.
types/ Interfaces TypeScript
utils/ URLs, fechas
```
## Backend de referencia
Producción: `https://testapp.digitaltelecom.net`

790
bun.lock

File diff suppressed because it is too large Load Diff

12
docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
ferco-admin:
build:
context: .
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-https://testapp.digitaltelecom.net}
image: ferco-admin:latest
container_name: ferco-admin
ports:
- "${APP_PORT:-8080}:80"
restart: unless-stopped

30
docker/nginx.conf Normal file
View File

@@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA — React Router
location / {
try_files $uri $uri/ /index.html;
}
# Cache de assets con hash de Vite
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types
text/plain
text/css
application/json
application/javascript
text/xml
application/xml
image/svg+xml;
}

View File

@@ -1,16 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ferco Admin — Remates ganaderos</title>
</head>
<body class="bg-surface text-slate-100 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,7 +0,0 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
};
export default nextConfig;

View File

@@ -1,30 +1,29 @@
{ {
"name": "panel-admin-remate", "name": "ferco-admin",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "vite",
"build": "next build", "build": "tsc --noEmit && vite build",
"start": "next start", "preview": "vite preview",
"lint": "eslint" "lint": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"next": "16.2.7", "@tanstack/react-query": "^5.62.8",
"react": "19.2.4", "react": "^18.3.1",
"react-dom": "19.2.4" "react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@types/node": "^22.10.2",
"eslint": "^9", "@types/react": "^18.3.12",
"eslint-config-next": "16.2.7", "@types/react-dom": "^18.3.1",
"tailwindcss": "^4" "@vitejs/plugin-react": "^4.3.4",
}, "autoprefixer": "^10.4.20",
"ignoreScripts": [ "postcss": "^8.4.49",
"sharp", "tailwindcss": "^3.4.16",
"unrs-resolver" "typescript": "^5.7.2",
], "vite": "^6.0.3"
"trustedDependencies": [ }
"sharp",
"unrs-resolver"
]
} }

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

4
public/vite.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#1a2332"/>
<path d="M8 22V10l8 6 8-6v12" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 250 B

50
src/App.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@/auth/AuthContext';
import { PrivateRoute } from '@/auth/PrivateRoute';
import { Layout } from '@/components/Layout';
import { LoginPage } from '@/pages/Login';
import { DashboardPage } from '@/pages/Dashboard';
import { UsersPage } from '@/pages/Users';
import { CabanasPage } from '@/pages/Cabanas';
import { RematesPage } from '@/pages/Remates';
import { LotesPage } from '@/pages/Lotes';
import { PujasPage } from '@/pages/Pujas';
import { LiveRoomPage } from '@/pages/LiveRoom';
import { HistorialPage } from '@/pages/Historial';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<PrivateRoute />}>
<Route element={<Layout />}>
<Route index element={<DashboardPage />} />
<Route path="usuarios" element={<UsersPage />} />
<Route path="cabanas" element={<CabanasPage />} />
<Route path="remates" element={<RematesPage />} />
<Route path="lotes" element={<LotesPage />} />
<Route path="pujas" element={<PujasPage />} />
<Route path="en-vivo" element={<LiveRoomPage />} />
<Route path="historial" element={<HistorialPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
}

17
src/api/cabanas.ts Normal file
View File

@@ -0,0 +1,17 @@
import { apiRequest } from './client';
import type { Cabana } from '@/types';
export const cabanasApi = {
list: () => apiRequest<Cabana[]>('/api/cabanas'),
get: (id: number) => apiRequest<Cabana>(`/api/cabanas/${id}`),
create: (body: Partial<Cabana>) =>
apiRequest<Cabana>('/api/cabanas/', { method: 'POST', body }),
update: (id: number, body: Partial<Cabana>) =>
apiRequest<Cabana>(`/api/cabanas/${id}`, { method: 'PUT', body }),
delete: (id: number) =>
apiRequest<void>(`/api/cabanas/${id}`, { method: 'DELETE' }),
};

93
src/api/client.ts Normal file
View File

@@ -0,0 +1,93 @@
import { apiUrl } from '@/utils/urls';
import type { ApiError } from '@/types';
const TOKEN_KEY = 'ferco_admin_token';
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(handler: () => void): void {
onUnauthorized = handler;
}
export function getStoredToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setStoredToken(token: string | null): void {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
export class ApiClientError extends Error {
status: number;
body?: ApiError;
constructor(message: string, status: number, body?: ApiError) {
super(message);
this.name = 'ApiClientError';
this.status = status;
this.body = body;
}
}
type RequestOptions = Omit<RequestInit, 'body'> & {
body?: unknown;
auth?: boolean;
};
export async function apiRequest<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { body, auth = true, headers: extraHeaders, ...init } = options;
const headers = new Headers(extraHeaders);
if (body !== undefined && !(body instanceof FormData)) {
headers.set('Content-Type', 'application/json');
}
if (auth) {
const token = getStoredToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
}
const response = await fetch(apiUrl(path), {
...init,
headers,
body:
body === undefined
? undefined
: body instanceof FormData
? body
: JSON.stringify(body),
});
if (response.status === 401 && auth) {
setStoredToken(null);
onUnauthorized?.();
throw new ApiClientError('Sesión expirada', 401);
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
let data: unknown = undefined;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!response.ok) {
const errBody = (typeof data === 'object' && data !== null ? data : {}) as ApiError;
const message =
errBody.error ?? errBody.message ?? `Error ${response.status}`;
throw new ApiClientError(message, response.status, errBody);
}
return data as T;
}

29
src/api/contador.ts Normal file
View File

@@ -0,0 +1,29 @@
import { apiRequest } from './client';
export const contadorApi = {
get: (remateId: number, loteId: number) =>
apiRequest<number>(`/contador/${remateId}/${loteId}`, { auth: false }),
incrementar: (remateId: number, loteId: number) =>
apiRequest<number>(`/contador/incrementar/${remateId}/${loteId}`, {
method: 'POST',
auth: false,
}),
reset: (remateId: number, loteId: number, valorInicial = 200) =>
apiRequest<number>(
`/contador/reset/${remateId}/${loteId}?valorInicial=${valorInicial}`,
{ method: 'POST', auth: false },
),
corregir: (
pujaId: number,
remateId: number,
loteId: number,
nuevoValor: number,
) =>
apiRequest<void>(
`/contador/corregir/pujaid/${pujaId}/remateid/${remateId}/loteid/${loteId}`,
{ method: 'PUT', body: { nuevoValor }, auth: false },
),
};

9
src/api/historial.ts Normal file
View File

@@ -0,0 +1,9 @@
import { apiRequest } from './client';
import type { HistorialLote, PujaRequest, PujaResponse } from '@/types';
export const historialApi = {
list: () => apiRequest<HistorialLote[]>('/api/HistorialPujas'),
registrar: (body: PujaRequest) =>
apiRequest<PujaResponse>('/api/HistorialPujas', { method: 'POST', body }),
};

32
src/api/lotes.ts Normal file
View File

@@ -0,0 +1,32 @@
import { apiRequest } from './client';
import type { Lote } from '@/types';
export const lotesApi = {
list: () => apiRequest<Lote[]>('/api/lotes'),
get: (id: number) => apiRequest<Lote>(`/api/lotes/${id}`),
create: (body: Partial<Lote>) =>
apiRequest<Lote>('/api/lotes', { method: 'POST', body }),
update: (id: number, body: Partial<Lote>) =>
apiRequest<Lote>(`/api/lotes/${id}`, { method: 'PUT', body }),
delete: (id: number) =>
apiRequest<void>(`/api/lotes/${id}`, { method: 'DELETE' }),
};
export const lotesWebfluxApi = {
list: () => apiRequest<Lote[]>('/api/webflux/lotes'),
get: (id: number) => apiRequest<Lote>(`/api/webflux/lotes/${id}`),
create: (body: Partial<Lote>) =>
apiRequest<Lote>('/api/webflux/lotes', { method: 'POST', body }),
update: (id: number, body: Partial<Lote>) =>
apiRequest<Lote>(`/api/webflux/lotes/${id}`, { method: 'PUT', body }),
delete: (id: number) =>
apiRequest<void>(`/api/webflux/lotes/${id}`, { method: 'DELETE' }),
};

31
src/api/pujas.ts Normal file
View File

@@ -0,0 +1,31 @@
import { apiRequest } from './client';
import type { Puja } from '@/types';
export const pujasApi = {
list: () => apiRequest<Puja[]>('/api/pujas'),
get: (id: number) => apiRequest<Puja>(`/api/pujas/id/${id}`),
byRemate: (remateId: number) =>
apiRequest<Puja[]>(`/api/pujas/remate/${remateId}`),
byLoteYRemate: (loteId: number, remateId: number) =>
apiRequest<Puja[]>(`/api/pujas/loteyremate/${loteId}/${remateId}`),
informe: (remateId: number) =>
apiRequest<Puja[]>(`/api/pujas/informe/${remateId}`),
informeNPujas: (remateId: number, limite = 3) =>
apiRequest<Puja[]>(
`/api/pujas/informe/npujas/${remateId}?limite=${limite}`,
),
create: (body: Partial<Puja>) =>
apiRequest<Puja>('/api/pujas', { method: 'POST', body }),
update: (id: number, body: Partial<Puja>) =>
apiRequest<Puja>(`/api/pujas/${id}`, { method: 'PUT', body }),
delete: (id: number) =>
apiRequest<void>(`/api/pujas/${id}`, { method: 'DELETE' }),
};

22
src/api/remates.ts Normal file
View File

@@ -0,0 +1,22 @@
import { apiRequest } from './client';
import type { Remate } from '@/types';
export const rematesApi = {
list: () => apiRequest<Remate[]>('/api/remates'),
get: (id: number) => apiRequest<Remate>(`/api/remates/${id}`),
create: (body: Partial<Remate>) =>
apiRequest<Remate>('/api/remates', { method: 'POST', body }),
update: (id: number, body: Partial<Remate>) =>
apiRequest<Remate>(`/api/remates/${id}`, { method: 'PUT', body }),
delete: (id: number) =>
apiRequest<void>(`/api/remates/${id}`, { method: 'DELETE' }),
finalizar: (remateId: number, loteId: number) =>
apiRequest<Remate>(`/api/remates/${remateId}/finalizar/${loteId}`, {
method: 'PUT',
}),
};

12
src/api/roles.ts Normal file
View File

@@ -0,0 +1,12 @@
import { apiRequest } from './client';
import type { Rol } from '@/types';
export const rolesApi = {
list: () => apiRequest<Rol[]>('/api/roles'),
create: (name: string) =>
apiRequest<Rol>('/api/roles', { method: 'POST', body: { name } }),
update: (id: number, name: string) =>
apiRequest<Rol>(`/api/roles/${id}`, { method: 'PUT', body: { name } }),
};

67
src/api/usuarios.ts Normal file
View File

@@ -0,0 +1,67 @@
import { apiRequest } from './client';
import type {
ActualizarUsuarioBody,
ConfirmadoTF,
LoginRequest,
LoginResponse,
MeResponse,
RegistrarUsuarioBody,
Usuario,
} from '@/types';
export const usuariosApi = {
login: (body: LoginRequest) =>
apiRequest<LoginResponse>('/api/usuarios/login', {
method: 'POST',
body,
auth: false,
}),
me: () => apiRequest<MeResponse>('/api/usuarios/me'),
list: () => apiRequest<Usuario[]>('/api/usuarios'),
pendientes: () => apiRequest<Usuario[]>('/api/usuarios/pendientes'),
aceptar: (id: number) =>
apiRequest<Usuario>(`/api/usuarios/aceptar/${id}`, { method: 'PATCH' }),
registrar: (body: RegistrarUsuarioBody) =>
apiRequest<Usuario>('/api/usuarios/registrar', {
method: 'POST',
body,
auth: false,
}),
update: (id: number, body: ActualizarUsuarioBody) =>
apiRequest<Usuario>(`/api/usuarios/${id}`, { method: 'PUT', body }),
delete: (id: number) =>
apiRequest<void>(`/api/usuarios/${id}`, { method: 'DELETE' }),
confirmado: (username: string) =>
apiRequest<ConfirmadoTF>(`/api/usuarios/confirmado/${encodeURIComponent(username)}`, {
auth: false,
}),
aprobado: (username: string) =>
apiRequest<boolean>(`/api/usuarios/aprobado/${encodeURIComponent(username)}`, {
auth: false,
}),
registrado: (username: string) =>
apiRequest<boolean>(`/api/usuarios/registrado/${encodeURIComponent(username)}`, {
auth: false,
}),
idByUsername: (username: string) =>
apiRequest<number>(`/api/usuarios/id/${encodeURIComponent(username)}`, {
auth: false,
}),
rolByUsername: (username: string) =>
apiRequest<{ rolId: number }>(
`/api/usuarios/rol/${encodeURIComponent(username)}`,
{ auth: false },
),
};

View File

@@ -1,26 +0,0 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,28 +0,0 @@
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

View File

@@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.js file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

122
src/auth/AuthContext.tsx Normal file
View File

@@ -0,0 +1,122 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { usuariosApi } from '@/api/usuarios';
import {
getStoredToken,
setStoredToken,
setUnauthorizedHandler,
} from '@/api/client';
import type { LoginRequest, MeResponse } from '@/types';
import { ApiClientError } from '@/api/client';
interface AuthContextValue {
user: MeResponse | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (credentials: LoginRequest) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const [user, setUser] = useState<MeResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const logout = useCallback(() => {
setStoredToken(null);
setUser(null);
navigate('/login', { replace: true });
}, [navigate]);
const refreshUser = useCallback(async () => {
const token = getStoredToken();
if (!token) {
setUser(null);
return;
}
const me = await usuariosApi.me();
setUser(me);
}, []);
useEffect(() => {
setUnauthorizedHandler(logout);
}, [logout]);
useEffect(() => {
let cancelled = false;
(async () => {
const token = getStoredToken();
if (!token) {
setIsLoading(false);
return;
}
try {
const me = await usuariosApi.me();
if (!cancelled) setUser(me);
} catch {
if (!cancelled) {
setStoredToken(null);
setUser(null);
}
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const login = useCallback(
async (credentials: LoginRequest) => {
const res = await usuariosApi.login(credentials);
if (!res.confirmed) {
throw new ApiClientError(
'Usuario pendiente de aprobación',
403,
{ error: 'Usuario pendiente de aprobación' },
);
}
setStoredToken(res.token);
setUser({
username: res.username,
userId: res.userId,
rolId: res.rolId,
confirmed: res.confirmed,
});
navigate('/', { replace: true });
},
[navigate],
);
const value = useMemo(
() => ({
user,
isAuthenticated: !!user && !!getStoredToken(),
isLoading,
login,
logout,
refreshUser,
}),
[user, isLoading, login, logout, refreshUser],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider');
return ctx;
}

21
src/auth/PrivateRoute.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
export function PrivateRoute() {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-surface">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,45 @@
import { Modal } from './Modal';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Eliminar',
onConfirm,
onCancel,
loading,
}: ConfirmDialogProps) {
return (
<Modal open={open} title={title} onClose={onCancel}>
<p className="mb-6 text-slate-300">{message}</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-4 py-2 text-sm text-slate-300 hover:bg-surface-border"
disabled={loading}
>
Cancelar
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50"
>
{loading ? 'Procesando…' : confirmLabel}
</button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,70 @@
import type { ReactNode } from 'react';
export interface Column<T> {
key: string;
header: string;
render: (row: T) => ReactNode;
className?: string;
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
keyExtractor: (row: T) => string | number;
emptyMessage?: string;
loading?: boolean;
}
export function DataTable<T>({
columns,
data,
keyExtractor,
emptyMessage = 'Sin registros',
loading,
}: DataTableProps<T>) {
if (loading) {
return (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
);
}
return (
<div className="overflow-x-auto rounded-xl border border-surface-border">
<table className="w-full min-w-[640px] text-left text-sm">
<thead className="bg-surface-raised text-slate-400">
<tr>
{columns.map((col) => (
<th key={col.key} className={`px-4 py-3 font-medium ${col.className ?? ''}`}>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-surface-border">
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="px-4 py-12 text-center text-slate-500"
>
{emptyMessage}
</td>
</tr>
) : (
data.map((row) => (
<tr key={keyExtractor(row)} className="hover:bg-surface-raised/50">
{columns.map((col) => (
<td key={col.key} className={`px-4 py-3 ${col.className ?? ''}`}>
{col.render(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,24 @@
export function ErrorAlert({
message,
onDismiss,
}: {
message: string;
onDismiss?: () => void;
}) {
if (!message) return null;
return (
<div className="flex items-start justify-between gap-3 rounded-lg border border-red-500/40 bg-red-950/50 px-4 py-3 text-sm text-red-200">
<span>{message}</span>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="shrink-0 text-red-300 hover:text-white"
aria-label="Cerrar"
>
×
</button>
)}
</div>
);
}

13
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
export function Layout() {
return (
<div className="flex min-h-screen bg-surface">
<Sidebar />
<main className="flex-1 overflow-auto p-6 lg:p-8">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export function LoadingSpinner({ className = '' }: { className?: string }) {
return (
<div
className={`inline-block h-8 w-8 animate-spin rounded-full border-2 border-accent border-t-transparent ${className}`}
role="status"
aria-label="Cargando"
/>
);
}

50
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { useEffect, type ReactNode } from 'react';
interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
wide?: boolean;
}
export function Modal({ open, title, onClose, children, wide }: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
className="absolute inset-0 bg-black/60"
aria-label="Cerrar modal"
onClick={onClose}
/>
<div
className={`relative z-10 max-h-[90vh] w-full overflow-y-auto rounded-xl border border-surface-border bg-surface-raised shadow-2xl ${
wide ? 'max-w-2xl' : 'max-w-lg'
}`}
>
<div className="flex items-center justify-between border-b border-surface-border px-5 py-4">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-slate-400 hover:bg-surface-border hover:text-white"
>
×
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
export function PageHeader({
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white">{title}</h1>
{description && (
<p className="mt-1 text-sm text-slate-400">{description}</p>
)}
</div>
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { NavLink } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
const links = [
{ to: '/', label: 'Dashboard', end: true },
{ to: '/usuarios', label: 'Usuarios' },
{ to: '/cabanas', label: 'Cabañas' },
{ to: '/remates', label: 'Remates' },
{ to: '/lotes', label: 'Lotes' },
{ to: '/pujas', label: 'Pujas' },
{ to: '/en-vivo', label: 'Sala en vivo' },
{ to: '/historial', label: 'Historial' },
];
export function Sidebar() {
const { user, logout } = useAuth();
return (
<aside className="flex w-56 shrink-0 flex-col border-r border-surface-border bg-surface-raised">
<div className="border-b border-surface-border px-4 py-5">
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">
Ferco Admin
</p>
<p className="mt-1 truncate text-sm text-slate-300">{user?.username}</p>
</div>
<nav className="flex-1 space-y-0.5 p-3">
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.end}
className={({ isActive }) =>
`block rounded-lg px-3 py-2 text-sm transition ${
isActive
? 'bg-accent-muted font-medium text-accent'
: 'text-slate-400 hover:bg-surface-border hover:text-white'
}`
}
>
{link.label}
</NavLink>
))}
</nav>
<div className="border-t border-surface-border p-3">
<button
type="button"
onClick={logout}
className="w-full rounded-lg px-3 py-2 text-left text-sm text-slate-400 hover:bg-surface-border hover:text-white"
>
Cerrar sesión
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,26 @@
import { Link } from 'react-router-dom';
interface StatCardProps {
label: string;
value: number | string;
to?: string;
accent?: string;
}
export function StatCard({ label, value, to, accent = 'text-accent' }: StatCardProps) {
const content = (
<div className="rounded-xl border border-surface-border bg-surface-raised p-5 transition hover:border-accent/50">
<p className="text-sm text-slate-400">{label}</p>
<p className={`mt-2 text-3xl font-bold ${accent}`}>{value}</p>
</div>
);
if (to) {
return (
<Link to={to} className="block">
{content}
</Link>
);
}
return content;
}

View File

@@ -0,0 +1,30 @@
import type { WsStatus } from '@/hooks/useWebSocket';
const labels: Record<WsStatus, string> = {
connecting: 'Conectando…',
open: 'Conectado',
closed: 'Desconectado',
error: 'Error',
};
const colors: Record<WsStatus, string> = {
connecting: 'bg-amber-500/20 text-amber-300',
open: 'bg-emerald-500/20 text-emerald-300',
closed: 'bg-slate-500/20 text-slate-400',
error: 'bg-red-500/20 text-red-300',
};
export function WsStatusBadge({ status }: { status: WsStatus }) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${colors[status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
status === 'open' ? 'bg-emerald-400' : 'bg-current opacity-70'
}`}
/>
WS {labels[status]}
</span>
);
}

View File

@@ -0,0 +1,52 @@
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react';
const inputClass =
'w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent';
export function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-400">{label}</span>
{children}
</label>
);
}
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
return <input className={inputClass} {...props} />;
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select className={inputClass} {...props} />;
}
export function Checkbox({
label,
...props
}: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
return (
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="rounded border-surface-border bg-surface text-accent focus:ring-accent"
{...props}
/>
{label}
</label>
);
}
export const btnPrimary =
'rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50';
export const btnSecondary =
'rounded-lg border border-surface-border px-4 py-2 text-sm text-slate-300 hover:bg-surface-border disabled:opacity-50';
export const btnDanger =
'rounded-lg bg-red-600/90 px-3 py-1.5 text-sm text-white hover:bg-red-500 disabled:opacity-50';

136
src/hooks/useContador.ts Normal file
View File

@@ -0,0 +1,136 @@
import { useCallback, useEffect, useState } from 'react';
import { contadorApi } from '@/api/contador';
import { useWebSocket, type WsStatus } from './useWebSocket';
import { wsUrl } from '@/utils/urls';
interface UseContadorOptions {
remateId: number | null;
loteId: number | null;
enabled?: boolean;
}
function parseContadorMessage(data: string): {
contador?: number;
finalizado?: boolean;
} {
if (data.startsWith('{')) {
try {
const json = JSON.parse(data) as { estado?: string };
if (json.estado === 'finalizado') return { finalizado: true };
} catch {
/* ignore */
}
return {};
}
const n = parseInt(data, 10);
if (!Number.isNaN(n)) return { contador: n };
return {};
}
export function useContador({ remateId, loteId, enabled = true }: UseContadorOptions) {
const [contador, setContador] = useState<number | null>(null);
const [finalizado, setFinalizado] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const wsPath =
remateId != null && loteId != null
? `/ws/puja/${remateId}/${loteId}`
: null;
const { status: wsStatus, send } = useWebSocket({
url: wsPath ? wsUrl(wsPath) : null,
enabled: enabled && !!wsPath,
onMessage: (data) => {
const parsed = parseContadorMessage(data);
if (parsed.finalizado) setFinalizado(true);
if (parsed.contador !== undefined) setContador(parsed.contador);
},
});
useEffect(() => {
setFinalizado(false);
setContador(null);
if (!enabled || remateId == null || loteId == null) return;
let cancelled = false;
contadorApi
.get(remateId, loteId)
.then((v) => {
if (!cancelled) setContador(v);
})
.catch(() => {
/* WS enviará valor inicial al conectar */
});
return () => {
cancelled = true;
};
}, [remateId, loteId, enabled]);
const incrementar = useCallback(async () => {
if (remateId == null || loteId == null) return;
setLoading(true);
setError(null);
try {
const v = await contadorApi.incrementar(remateId, loteId);
setContador(v);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error al incrementar');
send('incrementar');
} finally {
setLoading(false);
}
}, [remateId, loteId, send]);
const incrementarWs = useCallback(() => {
send('incrementar');
}, [send]);
const reset = useCallback(
async (valorInicial = 200) => {
if (remateId == null || loteId == null) return;
setLoading(true);
setError(null);
try {
const v = await contadorApi.reset(remateId, loteId, valorInicial);
setContador(v);
setFinalizado(false);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error al resetear');
} finally {
setLoading(false);
}
},
[remateId, loteId],
);
const corregir = useCallback(
async (pujaId: number, nuevoValor: number) => {
if (remateId == null || loteId == null) return;
setLoading(true);
setError(null);
try {
await contadorApi.corregir(pujaId, remateId, loteId, nuevoValor);
setContador(nuevoValor);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error al corregir');
} finally {
setLoading(false);
}
},
[remateId, loteId],
);
return {
contador,
finalizado,
loading,
error,
wsStatus: wsStatus as WsStatus,
incrementar,
incrementarWs,
reset,
corregir,
setContador,
};
}

View File

@@ -0,0 +1,21 @@
import { useState } from 'react';
import { useWebSocket, type WsStatus } from './useWebSocket';
import { wsUrl } from '@/utils/urls';
export function useEventosRemate(remateId: number | null, enabled = true) {
const [finRemate, setFinRemate] = useState(false);
const wsPath = remateId != null ? `/ws/eventos/${remateId}` : null;
const { status } = useWebSocket({
url: wsPath ? wsUrl(wsPath) : null,
enabled: enabled && !!wsPath,
onMessage: (data) => {
if (data === 'FIN_REMATE') setFinRemate(true);
},
});
const resetFin = () => setFinRemate(false);
return { finRemate, wsStatus: status as WsStatus, resetFin };
}

74
src/hooks/useLoteSSE.ts Normal file
View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react';
import { apiUrl } from '@/utils/urls';
import { getStoredToken } from '@/api/client';
import type { Lote } from '@/types';
export function useLoteSSE(loteId: number | null, enabled = false) {
const [lote, setLote] = useState<Lote | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled || loteId == null) {
setConnected(false);
return;
}
const controller = new AbortController();
let disposed = false;
(async () => {
const token = getStoredToken();
const headers: HeadersInit = { Accept: 'text/event-stream' };
if (token) headers.Authorization = `Bearer ${token}`;
try {
const res = await fetch(apiUrl(`/api/webflux/lotes/stream/${loteId}`), {
headers,
signal: controller.signal,
});
if (!res.ok || !res.body) {
throw new Error(`SSE error ${res.status}`);
}
if (!disposed) setConnected(true);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!disposed) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
for (const part of parts) {
const dataLine = part
.split('\n')
.find((l) => l.startsWith('data:'));
if (!dataLine) continue;
const json = dataLine.replace(/^data:\s*/, '');
try {
setLote(JSON.parse(json) as Lote);
} catch {
/* ignore */
}
}
}
} catch (e) {
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError')) {
setError(e instanceof Error ? e.message : 'Error SSE');
setConnected(false);
}
}
})();
return () => {
disposed = true;
controller.abort();
setConnected(false);
};
}, [loteId, enabled]);
return { lote, connected, error };
}

79
src/hooks/useWebSocket.ts Normal file
View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export type WsStatus = 'connecting' | 'open' | 'closed' | 'error';
interface UseWebSocketOptions {
url: string | null;
enabled?: boolean;
onMessage?: (data: string) => void;
reconnect?: boolean;
reconnectIntervalMs?: number;
}
export function useWebSocket({
url,
enabled = true,
onMessage,
reconnect = true,
reconnectIntervalMs = 3000,
}: UseWebSocketOptions) {
const [status, setStatus] = useState<WsStatus>('closed');
const wsRef = useRef<WebSocket | null>(null);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const send = useCallback((data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
}, []);
useEffect(() => {
if (!enabled || !url) {
setStatus('closed');
return;
}
let disposed = false;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
const connect = () => {
if (disposed) return;
setStatus('connecting');
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
if (!disposed) setStatus('open');
};
ws.onmessage = (event) => {
onMessageRef.current?.(String(event.data));
};
ws.onerror = () => {
if (!disposed) setStatus('error');
};
ws.onclose = () => {
if (!disposed) {
setStatus('closed');
if (reconnect) {
reconnectTimer = setTimeout(connect, reconnectIntervalMs);
}
}
};
};
connect();
return () => {
disposed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
wsRef.current?.close();
wsRef.current = null;
};
}, [url, enabled, reconnect, reconnectIntervalMs]);
return { status, send };
}

12
src/index.css Normal file
View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
min-height: 100vh;
}
#root {
min-height: 100vh;
}

10
src/main.tsx Normal file
View File

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

169
src/pages/Cabanas.tsx Normal file
View File

@@ -0,0 +1,169 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { cabanasApi } from '@/api/cabanas';
import type { Cabana } from '@/types';
import { PageHeader } from '@/components/PageHeader';
import { DataTable, type Column } from '@/components/DataTable';
import { Modal } from '@/components/Modal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ErrorAlert } from '@/components/ErrorAlert';
import {
Field,
Input,
Checkbox,
btnPrimary,
btnSecondary,
btnDanger,
} from '@/components/form/Field';
export function CabanasPage() {
const qc = useQueryClient();
const [modal, setModal] = useState<'create' | Cabana | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [error, setError] = useState('');
const query = useQuery({ queryKey: ['cabanas'], queryFn: cabanasApi.list });
const save = useMutation({
mutationFn: async (data: { id?: number; body: Partial<Cabana> }) => {
if (data.id) return cabanasApi.update(data.id, data.body);
return cabanasApi.create(data.body);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cabanas'] });
qc.invalidateQueries({ queryKey: ['dashboard-stats'] });
setModal(null);
setError('');
},
onError: (e: Error) => setError(e.message),
});
const remove = useMutation({
mutationFn: cabanasApi.delete,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cabanas'] });
setDeleteId(null);
},
});
const columns: Column<Cabana>[] = [
{ key: 'id', header: 'ID', render: (c) => c.id },
{ key: 'nombre', header: 'Nombre', render: (c) => c.nombre },
{ key: 'telefono', header: 'Teléfono', render: (c) => c.telefono },
{ key: 'visible', header: 'Visible', render: (c) => (c.visible ? 'Sí' : 'No') },
{
key: 'actions',
header: '',
render: (c) => (
<div className="flex gap-2">
<button type="button" className={btnSecondary} onClick={() => setModal(c)}>
Editar
</button>
<button type="button" className={btnDanger} onClick={() => setDeleteId(c.id)}>
Eliminar
</button>
</div>
),
},
];
return (
<div>
<PageHeader
title="Cabañas"
actions={
<button type="button" className={btnPrimary} onClick={() => setModal('create')}>
Nueva cabaña
</button>
}
/>
<DataTable
columns={columns}
data={query.data ?? []}
keyExtractor={(c) => c.id}
loading={query.isLoading}
/>
<CabanaFormModal
open={modal !== null}
cabana={modal === 'create' ? null : modal}
error={error}
loading={save.isPending}
onClose={() => {
setModal(null);
setError('');
}}
onSave={(body) =>
save.mutate({
id: modal !== 'create' && modal ? modal.id : undefined,
body,
})
}
/>
<ConfirmDialog
open={deleteId != null}
title="Eliminar cabaña"
message="¿Eliminar esta cabaña?"
onConfirm={() => deleteId != null && remove.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
loading={remove.isPending}
/>
</div>
);
}
function CabanaFormModal({
open,
cabana,
error,
loading,
onClose,
onSave,
}: {
open: boolean;
cabana: Cabana | null;
error: string;
loading: boolean;
onClose: () => void;
onSave: (body: Partial<Cabana>) => void;
}) {
const [nombre, setNombre] = useState(cabana?.nombre ?? '');
const [telefono, setTelefono] = useState(cabana?.telefono ?? '');
const [visible, setVisible] = useState(cabana?.visible ?? true);
return (
<Modal
open={open}
title={cabana ? 'Editar cabaña' : 'Nueva cabaña'}
onClose={onClose}
key={cabana?.id ?? 'new'}
>
{error && <div className="mb-4"><ErrorAlert message={error} /></div>}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
onSave({ nombre, telefono, visible });
}}
>
<Field label="Nombre">
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} required />
</Field>
<Field label="Teléfono">
<Input value={telefono} onChange={(e) => setTelefono(e.target.value)} required />
</Field>
<Checkbox label="Visible" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
<div className="flex justify-end gap-3">
<button type="button" className={btnSecondary} onClick={onClose}>
Cancelar
</button>
<button type="submit" className={btnPrimary} disabled={loading}>
Guardar
</button>
</div>
</form>
</Modal>
);
}

97
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,97 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { usuariosApi } from '@/api/usuarios';
import { rematesApi } from '@/api/remates';
import { lotesApi } from '@/api/lotes';
import { cabanasApi } from '@/api/cabanas';
import { pujasApi } from '@/api/pujas';
import { PageHeader } from '@/components/PageHeader';
import { StatCard } from '@/components/StatCard';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import { ErrorAlert } from '@/components/ErrorAlert';
export function DashboardPage() {
const stats = useQuery({
queryKey: ['dashboard-stats'],
queryFn: async () => {
const [usuarios, pendientes, remates, lotes, cabanas, pujas] =
await Promise.all([
usuariosApi.list(),
usuariosApi.pendientes(),
rematesApi.list(),
lotesApi.list(),
cabanasApi.list(),
pujasApi.list(),
]);
return {
usuarios: usuarios.length,
pendientes: pendientes.length,
remates: remates.length,
lotes: lotes.length,
cabanas: cabanas.length,
pujas: pujas.length,
};
},
});
const quickLinks = [
{ to: '/usuarios', label: 'Gestionar usuarios' },
{ to: '/remates', label: 'Crear remate' },
{ to: '/en-vivo', label: 'Sala en vivo' },
{ to: '/cabanas', label: 'Cabañas' },
];
return (
<div>
<PageHeader
title="Dashboard"
description="Resumen del sistema de remates"
/>
{stats.isError && (
<ErrorAlert
message={
stats.error instanceof Error
? stats.error.message
: 'Error al cargar estadísticas'
}
/>
)}
{stats.isLoading ? (
<div className="flex justify-center py-20">
<LoadingSpinner />
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard label="Usuarios" value={stats.data?.usuarios ?? 0} to="/usuarios" />
<StatCard
label="Pendientes aprobación"
value={stats.data?.pendientes ?? 0}
to="/usuarios"
accent="text-amber-400"
/>
<StatCard label="Remates" value={stats.data?.remates ?? 0} to="/remates" />
<StatCard label="Lotes" value={stats.data?.lotes ?? 0} to="/lotes" />
<StatCard label="Cabañas" value={stats.data?.cabanas ?? 0} to="/cabanas" />
<StatCard label="Pujas" value={stats.data?.pujas ?? 0} to="/pujas" />
</div>
)}
<section className="mt-10">
<h2 className="mb-4 text-lg font-semibold text-white">Accesos rápidos</h2>
<div className="flex flex-wrap gap-3">
{quickLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="rounded-lg border border-surface-border bg-surface-raised px-4 py-2 text-sm text-slate-300 transition hover:border-accent hover:text-white"
>
{link.label}
</Link>
))}
</div>
</section>
</div>
);
}

52
src/pages/Historial.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { useQuery } from '@tanstack/react-query';
import { historialApi } from '@/api/historial';
import { formatDateTime } from '@/utils/date';
import { PageHeader } from '@/components/PageHeader';
import { DataTable, type Column } from '@/components/DataTable';
import { ErrorAlert } from '@/components/ErrorAlert';
import type { HistorialLote } from '@/types';
export function HistorialPage() {
const query = useQuery({
queryKey: ['historial-pujas'],
queryFn: historialApi.list,
});
const columns: Column<HistorialLote>[] = [
{ key: 'id', header: 'ID', render: (h) => h.id },
{ key: 'monto', header: 'Monto', render: (h) => h.monto },
{ key: 'fecha', header: 'Fecha', render: (h) => formatDateTime(h.fecha) },
{ key: 'estado', header: 'Estado', render: (h) => h.estado },
{
key: 'usuario',
header: 'Usuario',
render: (h) => h.usuario?.username ?? h.usuario?.nombre ?? '—',
},
{ key: 'lote', header: 'Lote', render: (h) => h.lote?.nombre ?? h.lote?.id ?? '—' },
{ key: 'remate', header: 'Remate', render: (h) => h.remate?.nombre ?? h.remate?.id ?? '—' },
];
return (
<div>
<PageHeader
title="Historial de pujas"
description="Registro completo — GET /api/HistorialPujas"
/>
{query.isError && (
<ErrorAlert
message={
query.error instanceof Error ? query.error.message : 'Error al cargar'
}
/>
)}
<DataTable
columns={columns}
data={query.data ?? []}
keyExtractor={(h) => h.id}
loading={query.isLoading}
/>
</div>
);
}

269
src/pages/LiveRoom.tsx Normal file
View File

@@ -0,0 +1,269 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { rematesApi } from '@/api/remates';
import { lotesApi } from '@/api/lotes';
import { pujasApi } from '@/api/pujas';
import { PageHeader } from '@/components/PageHeader';
import { ErrorAlert } from '@/components/ErrorAlert';
import { WsStatusBadge } from '@/components/WsStatusBadge';
import { DataTable, type Column } from '@/components/DataTable';
import { useContador } from '@/hooks/useContador';
import { useEventosRemate } from '@/hooks/useEventosRemate';
import { formatDateTime } from '@/utils/date';
import type { Puja } from '@/types';
import {
Field,
Select,
Input,
btnPrimary,
btnSecondary,
} from '@/components/form/Field';
const REFRESH_MS = 4000;
export function LiveRoomPage() {
const qc = useQueryClient();
const [remateId, setRemateId] = useState('');
const [loteId, setLoteId] = useState('');
const [resetValor, setResetValor] = useState('200');
const [corregirPujaId, setCorregirPujaId] = useState('');
const [corregirValor, setCorregirValor] = useState('');
const [finalizarError, setFinalizarError] = useState('');
const [finalizando, setFinalizando] = useState(false);
const remateNum = remateId ? Number(remateId) : null;
const loteNum = loteId ? Number(loteId) : null;
const remates = useQuery({ queryKey: ['remates'], queryFn: rematesApi.list });
const lotes = useQuery({ queryKey: ['lotes'], queryFn: lotesApi.list });
const lotesDelRemate = (lotes.data ?? []).filter((l) => l.remate?.id === remateNum);
const {
contador,
finalizado: loteFinalizado,
loading: contadorLoading,
error: contadorError,
wsStatus,
incrementar,
incrementarWs,
reset,
corregir,
} = useContador({
remateId: remateNum,
loteId: loteNum,
enabled: remateNum != null && loteNum != null,
});
const { finRemate, wsStatus: eventosStatus, resetFin } = useEventosRemate(
remateNum,
remateNum != null,
);
const pujas = useQuery({
queryKey: ['pujas-live', remateNum, loteNum],
queryFn: () => pujasApi.byLoteYRemate(loteNum!, remateNum!),
enabled: remateNum != null && loteNum != null,
refetchInterval: REFRESH_MS,
});
useEffect(() => {
if (finRemate) {
qc.invalidateQueries({ queryKey: ['remates'] });
}
}, [finRemate, qc]);
const pujaColumns: Column<Puja>[] = [
{ key: 'id', header: 'ID', render: (p) => p.id },
{ key: 'monto', header: 'Monto', render: (p) => p.monto },
{ key: 'fecha', header: 'Fecha', render: (p) => formatDateTime(p.fecha) },
{ key: 'usuario', header: 'Usuario', render: (p) => p.usuario?.username ?? '—' },
];
const handleFinalizar = async () => {
if (remateNum == null || loteNum == null) return;
setFinalizarError('');
setFinalizando(true);
try {
await rematesApi.finalizar(remateNum, loteNum);
qc.invalidateQueries({ queryKey: ['remates'] });
resetFin();
} catch (e) {
setFinalizarError(e instanceof Error ? e.message : 'Error al finalizar');
} finally {
setFinalizando(false);
}
};
return (
<div>
<PageHeader
title="Sala de remate en vivo"
description="Operación en tiempo real: contador WS, eventos y pujas"
/>
<div className="mb-6 grid gap-4 rounded-xl border border-surface-border bg-surface-raised p-5 lg:grid-cols-2">
<Field label="Remate">
<Select
value={remateId}
onChange={(e) => {
setRemateId(e.target.value);
setLoteId('');
resetFin();
}}
>
<option value="">Seleccionar remate</option>
{(remates.data ?? []).map((r) => (
<option key={r.id} value={r.id}>
{r.nombre} ({r.estado})
</option>
))}
</Select>
</Field>
<Field label="Lote activo">
<Select
value={loteId}
onChange={(e) => setLoteId(e.target.value)}
disabled={!remateId}
>
<option value="">Seleccionar lote</option>
{lotesDelRemate.map((l) => (
<option key={l.id} value={l.id}>
#{l.numLote} {l.nombre}
</option>
))}
</Select>
</Field>
</div>
{remateNum != null && (
<div className="mb-4 flex flex-wrap items-center gap-3">
<WsStatusBadge status={eventosStatus} />
<span className="text-sm text-slate-400">Eventos /ws/eventos/{remateNum}</span>
{finRemate && (
<span className="rounded-full bg-amber-500/20 px-3 py-1 text-sm text-amber-200">
FIN_REMATE recibido
</span>
)}
</div>
)}
{remateNum != null && loteNum != null ? (
<>
<div className="mb-8 grid gap-6 lg:grid-cols-2">
<section className="rounded-2xl border border-accent/30 bg-accent-muted/30 p-8 text-center">
<p className="text-sm uppercase tracking-wider text-slate-400">
Contador en vivo
</p>
<p className="mt-2 text-7xl font-bold tabular-nums text-white">
{contador ?? '—'}
</p>
<div className="mt-4 flex justify-center gap-2">
<WsStatusBadge status={wsStatus} />
</div>
{(loteFinalizado || finRemate) && (
<p className="mt-3 text-amber-300">Lote o remate finalizado</p>
)}
{contadorError && (
<p className="mt-2 text-sm text-red-300">{contadorError}</p>
)}
<div className="mt-6 flex flex-wrap justify-center gap-3">
<button
type="button"
className={btnPrimary}
disabled={contadorLoading}
onClick={() => incrementar()}
>
Incrementar (REST)
</button>
<button
type="button"
className={btnSecondary}
disabled={contadorLoading}
onClick={() => incrementarWs()}
>
Incrementar (WS)
</button>
</div>
</section>
<section className="space-y-4 rounded-xl border border-surface-border bg-surface-raised p-5">
<h3 className="font-semibold text-white">Controles</h3>
<div className="flex flex-wrap items-end gap-3">
<Field label="Reset valor inicial">
<Input
type="number"
value={resetValor}
onChange={(e) => setResetValor(e.target.value)}
className="w-28"
/>
</Field>
<button
type="button"
className={btnSecondary}
disabled={contadorLoading}
onClick={() => reset(Number(resetValor) || 200)}
>
Reset contador
</button>
</div>
<div className="flex flex-wrap items-end gap-3">
<Field label="Puja ID">
<Input
type="number"
value={corregirPujaId}
onChange={(e) => setCorregirPujaId(e.target.value)}
className="w-28"
/>
</Field>
<Field label="Nuevo valor">
<Input
type="number"
value={corregirValor}
onChange={(e) => setCorregirValor(e.target.value)}
className="w-28"
/>
</Field>
<button
type="button"
className={btnSecondary}
disabled={contadorLoading || !corregirPujaId}
onClick={() =>
corregir(Number(corregirPujaId), Number(corregirValor))
}
>
Corregir
</button>
</div>
<hr className="border-surface-border" />
{finalizarError && <ErrorAlert message={finalizarError} />}
<button
type="button"
className="w-full rounded-lg bg-amber-600 py-2.5 text-sm font-medium text-white hover:bg-amber-500 disabled:opacity-50"
disabled={finalizando}
onClick={handleFinalizar}
>
{finalizando ? 'Finalizando…' : 'Finalizar remate (lote activo)'}
</button>
</section>
</div>
<section>
<h3 className="mb-3 text-lg font-semibold text-white">
Pujas del lote (actualización cada {REFRESH_MS / 1000}s)
</h3>
<DataTable
columns={pujaColumns}
data={pujas.data ?? []}
keyExtractor={(p) => p.id}
loading={pujas.isLoading}
emptyMessage="Sin pujas para este lote"
/>
</section>
</>
) : (
<p className="text-slate-500">Seleccione remate y lote para operar en vivo.</p>
)}
</div>
);
}

70
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { useState, type FormEvent } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { ApiClientError } from '@/api/client';
import { ErrorAlert } from '@/components/ErrorAlert';
import { Field, Input, btnPrimary } from '@/components/form/Field';
export function LoginPage() {
const { login, isAuthenticated, isLoading } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
if (!isLoading && isAuthenticated) {
return <Navigate to="/" replace />;
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await login({ username, password });
} catch (err) {
if (err instanceof ApiClientError) {
setError(err.message);
} else {
setError('No se pudo iniciar sesión');
}
} finally {
setSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-surface p-4">
<div className="w-full max-w-md rounded-2xl border border-surface-border bg-surface-raised p-8 shadow-xl">
<h1 className="text-2xl font-bold text-white">Panel administrador</h1>
<p className="mt-1 text-sm text-slate-400">Remates ganaderos Ferco</p>
<form onSubmit={handleSubmit} className="mt-8 space-y-4">
{error && <ErrorAlert message={error} onDismiss={() => setError('')} />}
<Field label="Correo electrónico">
<Input
type="email"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="email@ejemplo.com"
/>
</Field>
<Field label="Contraseña">
<Input
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
<button type="submit" className={`${btnPrimary} w-full`} disabled={submitting}>
{submitting ? 'Ingresando…' : 'Iniciar sesión'}
</button>
</form>
</div>
</div>
);
}

276
src/pages/Lotes.tsx Normal file
View File

@@ -0,0 +1,276 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { lotesApi } from '@/api/lotes';
import { rematesApi } from '@/api/remates';
import { cabanasApi } from '@/api/cabanas';
import type { Lote } from '@/types';
import { PageHeader } from '@/components/PageHeader';
import { DataTable, type Column } from '@/components/DataTable';
import { Modal } from '@/components/Modal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ErrorAlert } from '@/components/ErrorAlert';
import { useLoteSSE } from '@/hooks/useLoteSSE';
import {
Field,
Input,
Select,
Checkbox,
btnPrimary,
btnSecondary,
btnDanger,
} from '@/components/form/Field';
export function LotesPage() {
const qc = useQueryClient();
const [modal, setModal] = useState<'create' | Lote | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [error, setError] = useState('');
const [sseLoteId, setSseLoteId] = useState<number | null>(null);
const [sseEnabled, setSseEnabled] = useState(false);
const query = useQuery({ queryKey: ['lotes'], queryFn: lotesApi.list });
const remates = useQuery({ queryKey: ['remates'], queryFn: rematesApi.list });
const cabanas = useQuery({ queryKey: ['cabanas'], queryFn: cabanasApi.list });
const sse = useLoteSSE(sseLoteId, sseEnabled);
const save = useMutation({
mutationFn: async (data: { id?: number; body: Partial<Lote> }) => {
if (data.id) return lotesApi.update(data.id, data.body);
return lotesApi.create(data.body);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['lotes'] });
setModal(null);
setError('');
},
onError: (e: Error) => setError(e.message),
});
const remove = useMutation({
mutationFn: lotesApi.delete,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['lotes'] });
setDeleteId(null);
},
});
const columns: Column<Lote>[] = [
{ key: 'id', header: 'ID', render: (l) => l.id },
{ key: 'num', header: 'Nº', render: (l) => l.numLote },
{ key: 'nombre', header: 'Nombre', render: (l) => l.nombre },
{ key: 'puja', header: 'Puja', render: (l) => l.puja },
{ key: 'estado', header: 'Estado', render: (l) => l.estado },
{ key: 'remate', header: 'Remate', render: (l) => l.remate?.id ?? '—' },
{
key: 'actions',
header: '',
render: (l) => (
<div className="flex flex-wrap gap-2">
<button type="button" className={btnSecondary} onClick={() => setModal(l)}>
Editar
</button>
<button
type="button"
className={btnSecondary}
onClick={() => {
setSseLoteId(l.id);
setSseEnabled(true);
}}
>
SSE
</button>
<button type="button" className={btnDanger} onClick={() => setDeleteId(l.id)}>
Eliminar
</button>
</div>
),
},
];
return (
<div>
<PageHeader
title="Lotes"
actions={
<button type="button" className={btnPrimary} onClick={() => setModal('create')}>
Nuevo lote
</button>
}
/>
{error && (
<div className="mb-4">
<ErrorAlert message={error} onDismiss={() => setError('')} />
</div>
)}
{sseEnabled && sseLoteId != null && (
<div className="mb-4 rounded-lg border border-surface-border bg-surface-raised p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm text-slate-300">
SSE lote #{sseLoteId} {sse.connected ? 'Conectado' : 'Desconectado'}
</span>
<button
type="button"
className={btnSecondary}
onClick={() => {
setSseEnabled(false);
setSseLoteId(null);
}}
>
Cerrar SSE
</button>
</div>
{sse.error && <p className="mt-2 text-sm text-red-300">{sse.error}</p>}
{sse.lote && (
<pre className="mt-3 max-h-40 overflow-auto rounded bg-surface p-3 text-xs text-slate-300">
{JSON.stringify(sse.lote, null, 2)}
</pre>
)}
</div>
)}
<DataTable
columns={columns}
data={query.data ?? []}
keyExtractor={(l) => l.id}
loading={query.isLoading}
/>
<LoteFormModal
open={modal !== null}
lote={modal === 'create' ? null : modal}
remates={remates.data ?? []}
cabanas={cabanas.data ?? []}
loading={save.isPending}
onClose={() => setModal(null)}
onSave={(body) =>
save.mutate({
id: modal !== 'create' && modal ? modal.id : undefined,
body,
})
}
/>
<ConfirmDialog
open={deleteId != null}
title="Eliminar lote"
message="¿Eliminar este lote?"
onConfirm={() => deleteId != null && remove.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
loading={remove.isPending}
/>
</div>
);
}
function LoteFormModal({
open,
lote,
remates,
cabanas,
loading,
onClose,
onSave,
}: {
open: boolean;
lote: Lote | null;
remates: { id: number; nombre: string }[];
cabanas: { id: number; nombre: string }[];
loading: boolean;
onClose: () => void;
onSave: (body: Partial<Lote>) => void;
}) {
const [nombre, setNombre] = useState(lote?.nombre ?? '');
const [numLote, setNumLote] = useState(String(lote?.numLote ?? ''));
const [precio, setPrecio] = useState(String(lote?.precio ?? ''));
const [raza, setRaza] = useState(lote?.raza ?? '');
const [prelance, setPrelance] = useState(String(lote?.prelance ?? ''));
const [puja, setPuja] = useState(String(lote?.puja ?? ''));
const [estado, setEstado] = useState(lote?.estado ?? '');
const [video, setVideo] = useState(lote?.video ?? '');
const [visible, setVisible] = useState(lote?.visible ?? true);
const [remateId, setRemateId] = useState(String(lote?.remate?.id ?? ''));
const [cabanaId, setCabanaId] = useState(String(lote?.cabana?.id ?? ''));
return (
<Modal open={open} title={lote ? 'Editar lote' : 'Nuevo lote'} onClose={onClose} wide>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
onSave({
nombre,
numLote: Number(numLote),
precio: Number(precio),
raza,
prelance: Number(prelance),
puja: Number(puja),
estado,
video: video || undefined,
visible,
remate: { id: Number(remateId) } as Lote['remate'],
cabana: { id: Number(cabanaId) } as Lote['cabana'],
});
}}
>
<Field label="Nombre">
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} required />
</Field>
<Field label="Nº lote">
<Input type="number" value={numLote} onChange={(e) => setNumLote(e.target.value)} required />
</Field>
<Field label="Precio">
<Input type="number" value={precio} onChange={(e) => setPrecio(e.target.value)} />
</Field>
<Field label="Raza">
<Input value={raza} onChange={(e) => setRaza(e.target.value)} />
</Field>
<Field label="Prelance">
<Input type="number" value={prelance} onChange={(e) => setPrelance(e.target.value)} />
</Field>
<Field label="Puja actual">
<Input type="number" value={puja} onChange={(e) => setPuja(e.target.value)} />
</Field>
<Field label="Estado">
<Input value={estado} onChange={(e) => setEstado(e.target.value)} />
</Field>
<Field label="Video URL">
<Input value={video} onChange={(e) => setVideo(e.target.value)} />
</Field>
<Field label="Remate">
<Select value={remateId} onChange={(e) => setRemateId(e.target.value)} required>
<option value="">Seleccionar</option>
{remates.map((r) => (
<option key={r.id} value={r.id}>
{r.nombre}
</option>
))}
</Select>
</Field>
<Field label="Cabaña">
<Select value={cabanaId} onChange={(e) => setCabanaId(e.target.value)} required>
<option value="">Seleccionar</option>
{cabanas.map((c) => (
<option key={c.id} value={c.id}>
{c.nombre}
</option>
))}
</Select>
</Field>
<div className="sm:col-span-2">
<Checkbox label="Visible" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
</div>
<div className="flex justify-end gap-3 sm:col-span-2">
<button type="button" className={btnSecondary} onClick={onClose}>
Cancelar
</button>
<button type="submit" className={btnPrimary} disabled={loading}>
Guardar
</button>
</div>
</form>
</Modal>
);
}

178
src/pages/Pujas.tsx Normal file
View File

@@ -0,0 +1,178 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { pujasApi } from '@/api/pujas';
import { rematesApi } from '@/api/remates';
import type { Puja } from '@/types';
import { formatDateTime } from '@/utils/date';
import { PageHeader } from '@/components/PageHeader';
import { DataTable, type Column } from '@/components/DataTable';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { Field, Select, btnSecondary, btnDanger } from '@/components/form/Field';
export function PujasPage() {
const qc = useQueryClient();
const [remateFilter, setRemateFilter] = useState('');
const [loteFilter, setLoteFilter] = useState('');
const [informeRemateId, setInformeRemateId] = useState('');
const [deleteId, setDeleteId] = useState<number | null>(null);
const remates = useQuery({ queryKey: ['remates'], queryFn: rematesApi.list });
const allPujas = useQuery({ queryKey: ['pujas'], queryFn: pujasApi.list });
const filtered = useQuery({
queryKey: ['pujas-filtered', remateFilter, loteFilter],
queryFn: async () => {
const remateId = Number(remateFilter);
const loteId = Number(loteFilter);
if (loteFilter && remateFilter) {
return pujasApi.byLoteYRemate(loteId, remateId);
}
if (remateFilter) return pujasApi.byRemate(remateId);
return pujasApi.list();
},
enabled: true,
});
const informe = useQuery({
queryKey: ['pujas-informe', informeRemateId],
queryFn: () => pujasApi.informe(Number(informeRemateId)),
enabled: !!informeRemateId,
});
const ultimas = useQuery({
queryKey: ['pujas-npujas', informeRemateId],
queryFn: () => pujasApi.informeNPujas(Number(informeRemateId), 5),
enabled: !!informeRemateId,
});
const remove = useMutation({
mutationFn: pujasApi.delete,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['pujas'] });
qc.invalidateQueries({ queryKey: ['pujas-filtered'] });
setDeleteId(null);
},
});
const displayData = useMemo(() => {
if (remateFilter || loteFilter) return filtered.data ?? [];
return allPujas.data ?? [];
}, [remateFilter, loteFilter, filtered.data, allPujas.data]);
const loading = remateFilter || loteFilter ? filtered.isLoading : allPujas.isLoading;
const columns: Column<Puja>[] = [
{ key: 'id', header: 'ID', render: (p) => p.id },
{ key: 'monto', header: 'Monto', render: (p) => p.monto },
{ key: 'fecha', header: 'Fecha', render: (p) => formatDateTime(p.fecha) },
{ key: 'usuario', header: 'Usuario', render: (p) => p.usuario?.username ?? '—' },
{ key: 'lote', header: 'Lote', render: (p) => p.lote?.id ?? '—' },
{
key: 'actions',
header: '',
render: (p) => (
<button type="button" className={btnDanger} onClick={() => setDeleteId(p.id)}>
Eliminar
</button>
),
},
];
const informeColumns: Column<Puja>[] = [
{ key: 'lote', header: 'Lote', render: (p) => p.lote?.id ?? '—' },
{ key: 'monto', header: 'Mayor puja', render: (p) => p.monto },
{ key: 'usuario', header: 'Usuario', render: (p) => p.usuario?.username ?? '—' },
];
return (
<div>
<PageHeader title="Pujas" description="Listado, filtros e informes" />
<div className="mb-6 flex flex-wrap gap-4 rounded-xl border border-surface-border bg-surface-raised p-4">
<Field label="Filtrar por remate">
<Select value={remateFilter} onChange={(e) => setRemateFilter(e.target.value)}>
<option value="">Todos</option>
{(remates.data ?? []).map((r) => (
<option key={r.id} value={r.id}>
{r.nombre}
</option>
))}
</Select>
</Field>
<Field label="ID lote (opcional, requiere remate)">
<input
type="number"
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-white"
placeholder="ID lote"
value={loteFilter}
onChange={(e) => setLoteFilter(e.target.value)}
/>
</Field>
<Field label="Informe remate">
<Select
value={informeRemateId}
onChange={(e) => setInformeRemateId(e.target.value)}
>
<option value=""></option>
{(remates.data ?? []).map((r) => (
<option key={r.id} value={r.id}>
{r.nombre}
</option>
))}
</Select>
</Field>
<button
type="button"
className={`${btnSecondary} self-end`}
onClick={() => {
setRemateFilter('');
setLoteFilter('');
}}
>
Limpiar filtros
</button>
</div>
<DataTable
columns={columns}
data={displayData}
keyExtractor={(p) => p.id}
loading={loading}
/>
{informeRemateId && (
<div className="mt-10 grid gap-8 lg:grid-cols-2">
<section>
<h2 className="mb-3 text-lg font-semibold">Informe mayor puja por lote</h2>
<DataTable
columns={informeColumns}
data={informe.data ?? []}
keyExtractor={(p) => `inf-${p.id}-${p.lote?.id}`}
loading={informe.isLoading}
emptyMessage="Sin datos de informe"
/>
</section>
<section>
<h2 className="mb-3 text-lg font-semibold">Últimas 5 pujas del remate</h2>
<DataTable
columns={columns.filter((c) => c.key !== 'actions')}
data={ultimas.data ?? []}
keyExtractor={(p) => p.id}
loading={ultimas.isLoading}
emptyMessage="Sin pujas recientes"
/>
</section>
</div>
)}
<ConfirmDialog
open={deleteId != null}
title="Eliminar puja"
message="¿Eliminar esta puja?"
onConfirm={() => deleteId != null && remove.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
loading={remove.isPending}
/>
</div>
);
}

313
src/pages/Remates.tsx Normal file
View File

@@ -0,0 +1,313 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { rematesApi } from '@/api/remates';
import { cabanasApi } from '@/api/cabanas';
import type { Remate } from '@/types';
import { formatDateTime, toDatetimeLocalValue, datetimeLocalToIso } from '@/utils/date';
import { PageHeader } from '@/components/PageHeader';
import { DataTable, type Column } from '@/components/DataTable';
import { Modal } from '@/components/Modal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ErrorAlert } from '@/components/ErrorAlert';
import { WsStatusBadge } from '@/components/WsStatusBadge';
import { useEventosRemate } from '@/hooks/useEventosRemate';
import {
Field,
Input,
Select,
Checkbox,
btnPrimary,
btnSecondary,
btnDanger,
} from '@/components/form/Field';
export function RematesPage() {
const qc = useQueryClient();
const [modal, setModal] = useState<'create' | Remate | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [error, setError] = useState('');
const [wsRemateId, setWsRemateId] = useState<number | null>(null);
const [finalizar, setFinalizar] = useState<{ remateId: number; loteId: string } | null>(
null,
);
const query = useQuery({ queryKey: ['remates'], queryFn: rematesApi.list });
const cabanas = useQuery({ queryKey: ['cabanas'], queryFn: cabanasApi.list });
const { finRemate, wsStatus, resetFin } = useEventosRemate(wsRemateId, wsRemateId != null);
const save = useMutation({
mutationFn: async (data: { id?: number; body: Partial<Remate> }) => {
if (data.id) return rematesApi.update(data.id, data.body);
return rematesApi.create(data.body);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['remates'] });
setModal(null);
setError('');
},
onError: (e: Error) => setError(e.message),
});
const remove = useMutation({
mutationFn: rematesApi.delete,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['remates'] });
setDeleteId(null);
},
});
const finalizarMut = useMutation({
mutationFn: ({ remateId, loteId }: { remateId: number; loteId: number }) =>
rematesApi.finalizar(remateId, loteId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['remates'] });
setFinalizar(null);
},
onError: (e: Error) => setError(e.message),
});
const columns: Column<Remate>[] = [
{ key: 'id', header: 'ID', render: (r) => r.id },
{ key: 'nombre', header: 'Nombre', render: (r) => r.nombre },
{ key: 'fecha', header: 'Inicio', render: (r) => formatDateTime(r.fecha) },
{ key: 'fechaFin', header: 'Fin', render: (r) => formatDateTime(r.fechaFin) },
{ key: 'estado', header: 'Estado', render: (r) => r.estado },
{ key: 'cabana', header: 'Cabaña', render: (r) => r.cabana?.nombre ?? '—' },
{
key: 'actions',
header: '',
render: (r) => (
<div className="flex flex-wrap gap-2">
<button type="button" className={btnSecondary} onClick={() => setModal(r)}>
Editar
</button>
<button
type="button"
className={btnSecondary}
onClick={() => {
setWsRemateId(r.id);
resetFin();
}}
>
WS eventos
</button>
<button
type="button"
className="rounded-lg bg-amber-600/90 px-3 py-1.5 text-sm text-white hover:bg-amber-500"
onClick={() => setFinalizar({ remateId: r.id, loteId: '' })}
>
Finalizar
</button>
<button type="button" className={btnDanger} onClick={() => setDeleteId(r.id)}>
Eliminar
</button>
</div>
),
},
];
return (
<div>
<PageHeader
title="Remates"
actions={
<button type="button" className={btnPrimary} onClick={() => setModal('create')}>
Nuevo remate
</button>
}
/>
{error && (
<div className="mb-4">
<ErrorAlert message={error} onDismiss={() => setError('')} />
</div>
)}
{wsRemateId != null && (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-surface-border bg-surface-raised px-4 py-3">
<span className="text-sm text-slate-300">
Escuchando eventos remate #{wsRemateId}
</span>
<WsStatusBadge status={wsStatus} />
{finRemate && (
<span className="text-sm font-medium text-amber-300">
Evento recibido: FIN_REMATE
</span>
)}
<button
type="button"
className={btnSecondary}
onClick={() => setWsRemateId(null)}
>
Detener
</button>
</div>
)}
<DataTable
columns={columns}
data={query.data ?? []}
keyExtractor={(r) => r.id}
loading={query.isLoading}
/>
<RemateFormModal
open={modal !== null}
remate={modal === 'create' ? null : modal}
cabanas={cabanas.data ?? []}
loading={save.isPending}
onClose={() => setModal(null)}
onSave={(body) =>
save.mutate({
id: modal !== 'create' && modal ? modal.id : undefined,
body,
})
}
/>
<Modal
open={finalizar != null}
title="Finalizar remate"
onClose={() => setFinalizar(null)}
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!finalizar) return;
const loteId = Number(finalizar.loteId);
if (!loteId) return;
finalizarMut.mutate({ remateId: finalizar.remateId, loteId });
}}
>
<p className="text-sm text-slate-400">
Remate #{finalizar?.remateId}. Indique el lote activo al finalizar.
</p>
<Field label="ID del lote">
<Input
type="number"
required
value={finalizar?.loteId ?? ''}
onChange={(e) =>
setFinalizar((f) => (f ? { ...f, loteId: e.target.value } : f))
}
/>
</Field>
<div className="flex justify-end gap-3">
<button type="button" className={btnSecondary} onClick={() => setFinalizar(null)}>
Cancelar
</button>
<button type="submit" className={btnPrimary} disabled={finalizarMut.isPending}>
Finalizar remate
</button>
</div>
</form>
</Modal>
<ConfirmDialog
open={deleteId != null}
title="Eliminar remate"
message="¿Eliminar este remate?"
onConfirm={() => deleteId != null && remove.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
loading={remove.isPending}
/>
</div>
);
}
function RemateFormModal({
open,
remate,
cabanas,
loading,
onClose,
onSave,
}: {
open: boolean;
remate: Remate | null;
cabanas: { id: number; nombre: string }[];
loading: boolean;
onClose: () => void;
onSave: (body: Partial<Remate>) => void;
}) {
const [nombre, setNombre] = useState(remate?.nombre ?? '');
const [fecha, setFecha] = useState(toDatetimeLocalValue(remate?.fecha));
const [fechaFin, setFechaFin] = useState(toDatetimeLocalValue(remate?.fechaFin));
const [urlListaLotes, setUrlListaLotes] = useState(remate?.urlListaLotes ?? '');
const [estado, setEstado] = useState(remate?.estado ?? 'ACTIVO');
const [visible, setVisible] = useState(remate?.visible ?? true);
const [cabanaId, setCabanaId] = useState(String(remate?.cabana?.id ?? ''));
return (
<Modal open={open} title={remate ? 'Editar remate' : 'Nuevo remate'} onClose={onClose} wide>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
onSave({
nombre,
fecha: datetimeLocalToIso(fecha),
fechaFin: datetimeLocalToIso(fechaFin),
urlListaLotes: urlListaLotes || undefined,
estado,
visible,
cabana: { id: Number(cabanaId) } as Remate['cabana'],
});
}}
>
<Field label="Nombre">
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} required />
</Field>
<Field label="Cabaña">
<Select
value={cabanaId}
onChange={(e) => setCabanaId(e.target.value)}
required
>
<option value="">Seleccionar</option>
{cabanas.map((c) => (
<option key={c.id} value={c.id}>
{c.nombre}
</option>
))}
</Select>
</Field>
<Field label="Fecha inicio">
<Input
type="datetime-local"
value={fecha}
onChange={(e) => setFecha(e.target.value)}
required
/>
</Field>
<Field label="Fecha fin">
<Input
type="datetime-local"
value={fechaFin}
onChange={(e) => setFechaFin(e.target.value)}
required
/>
</Field>
<Field label="Estado">
<Input value={estado} onChange={(e) => setEstado(e.target.value)} />
</Field>
<Field label="URL lista lotes">
<Input value={urlListaLotes} onChange={(e) => setUrlListaLotes(e.target.value)} />
</Field>
<div className="sm:col-span-2">
<Checkbox label="Visible" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
</div>
<div className="flex justify-end gap-3 sm:col-span-2">
<button type="button" className={btnSecondary} onClick={onClose}>
Cancelar
</button>
<button type="submit" className={btnPrimary} disabled={loading}>
Guardar
</button>
</div>
</form>
</Modal>
);
}

252
src/pages/Users.tsx Normal file
View File

@@ -0,0 +1,252 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { usuariosApi } from '@/api/usuarios';
import { rolesApi } from '@/api/roles';
import type { ActualizarUsuarioBody, Usuario } from '@/types';
import { PageHeader } from '@/components/PageHeader';
import { DataTable, type Column } from '@/components/DataTable';
import { Modal } from '@/components/Modal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ErrorAlert } from '@/components/ErrorAlert';
import {
Field,
Input,
Select,
Checkbox,
btnPrimary,
btnSecondary,
btnDanger,
} from '@/components/form/Field';
export function UsersPage() {
const qc = useQueryClient();
const [editUser, setEditUser] = useState<Usuario | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [formError, setFormError] = useState('');
const usuarios = useQuery({ queryKey: ['usuarios'], queryFn: usuariosApi.list });
const pendientes = useQuery({
queryKey: ['usuarios-pendientes'],
queryFn: usuariosApi.pendientes,
});
const roles = useQuery({ queryKey: ['roles'], queryFn: rolesApi.list });
const aceptar = useMutation({
mutationFn: (id: number) => usuariosApi.aceptar(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['usuarios'] });
qc.invalidateQueries({ queryKey: ['usuarios-pendientes'] });
qc.invalidateQueries({ queryKey: ['dashboard-stats'] });
},
});
const update = useMutation({
mutationFn: ({ id, body }: { id: number; body: ActualizarUsuarioBody }) =>
usuariosApi.update(id, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['usuarios'] });
qc.invalidateQueries({ queryKey: ['usuarios-pendientes'] });
setEditUser(null);
},
onError: (e: Error) => setFormError(e.message),
});
const remove = useMutation({
mutationFn: (id: number) => usuariosApi.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['usuarios'] });
setDeleteId(null);
},
});
const columns: Column<Usuario>[] = [
{ key: 'id', header: 'ID', render: (u) => u.id },
{ key: 'email', header: 'Email', render: (u) => u.username },
{ key: 'nombre', header: 'Nombre', render: (u) => u.nombre },
{
key: 'rol',
header: 'Rol',
render: (u) => u.rol?.name ?? u.roles ?? '—',
},
{
key: 'aprobado',
header: 'Aprobado',
render: (u) => (u.aprobado ? 'Sí' : 'No'),
},
{
key: 'visible',
header: 'Visible',
render: (u) => (u.visible ? 'Sí' : 'No'),
},
{
key: 'actions',
header: '',
render: (u) => (
<div className="flex gap-2">
<button type="button" className={btnSecondary} onClick={() => setEditUser(u)}>
Editar
</button>
<button type="button" className={btnDanger} onClick={() => setDeleteId(u.id)}>
Eliminar
</button>
</div>
),
},
];
return (
<div>
<PageHeader
title="Usuarios"
description="Gestión de cuentas y aprobaciones"
/>
{pendientes.data && pendientes.data.length > 0 && (
<section className="mb-8 rounded-xl border border-amber-500/30 bg-amber-950/20 p-5">
<h2 className="mb-3 font-semibold text-amber-200">
Pendientes de aprobación ({pendientes.data.length})
</h2>
<ul className="space-y-2">
{pendientes.data.map((u) => (
<li
key={u.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-surface-raised/80 px-4 py-3"
>
<span>
<strong>{u.nombre}</strong> {u.username}
</span>
<button
type="button"
className={btnPrimary}
disabled={aceptar.isPending}
onClick={() => aceptar.mutate(u.id)}
>
Aprobar
</button>
</li>
))}
</ul>
</section>
)}
<DataTable
columns={columns}
data={usuarios.data ?? []}
keyExtractor={(u) => u.id}
loading={usuarios.isLoading}
/>
{editUser && (
<UserFormModal
user={editUser}
roles={roles.data ?? []}
error={formError}
loading={update.isPending}
onClose={() => {
setEditUser(null);
setFormError('');
}}
onSave={(body) => update.mutate({ id: editUser.id, body })}
/>
)}
<ConfirmDialog
open={deleteId != null}
title="Eliminar usuario"
message="¿Confirma eliminar este usuario? Esta acción no se puede deshacer."
onConfirm={() => deleteId != null && remove.mutate(deleteId)}
onCancel={() => setDeleteId(null)}
loading={remove.isPending}
/>
</div>
);
}
function UserFormModal({
user,
roles,
error,
loading,
onClose,
onSave,
}: {
user: Usuario;
roles: { id: number; name: string }[];
error: string;
loading: boolean;
onClose: () => void;
onSave: (body: ActualizarUsuarioBody) => void;
}) {
const [nombre, setNombre] = useState(user.nombre);
const [ci, setCi] = useState(String(user.ci));
const [celular, setCelular] = useState(String(user.celular));
const [aprobado, setAprobado] = useState(user.aprobado);
const [visible, setVisible] = useState(user.visible);
const [rolId, setRolId] = useState(String(user.rol?.id ?? ''));
const [password, setPassword] = useState('');
return (
<Modal open title={`Editar: ${user.username}`} onClose={onClose}>
{error && <div className="mb-4"><ErrorAlert message={error} /></div>}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
const body: ActualizarUsuarioBody = {
nombre,
ci: Number(ci),
celular: Number(celular),
aprobado,
visible,
rol: rolId ? Number(rolId) : undefined,
};
if (password) body.password = password;
onSave(body);
}}
>
<Field label="Nombre">
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} required />
</Field>
<Field label="CI">
<Input type="number" value={ci} onChange={(e) => setCi(e.target.value)} required />
</Field>
<Field label="Celular">
<Input
type="number"
value={celular}
onChange={(e) => setCelular(e.target.value)}
required
/>
</Field>
<Field label="Rol (tabla roles)">
<Select value={rolId} onChange={(e) => setRolId(e.target.value)}>
<option value=""> Sin cambio </option>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Select>
</Field>
<Field label="Nueva contraseña (opcional)">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</Field>
<Checkbox label="Aprobado" checked={aprobado} onChange={(e) => setAprobado(e.target.checked)} />
<Checkbox label="Visible" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
<div className="flex justify-end gap-3 pt-2">
<button type="button" className={btnSecondary} onClick={onClose}>
Cancelar
</button>
<button type="submit" className={btnPrimary} disabled={loading}>
Guardar
</button>
</div>
</form>
</Modal>
);
}

144
src/types/index.ts Normal file
View File

@@ -0,0 +1,144 @@
export type RolEnum = 'CLIENTE' | 'ADMIN' | 'COLABORADOR' | 'SUPER_USUARIO';
export interface Rol {
id: number;
name: string;
}
export interface Usuario {
id: number;
username: string;
nombre: string;
ci: number;
celular: number;
aprobado: boolean;
visible: boolean;
rol?: Rol;
roles?: RolEnum | null;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
tokenType: string;
expiresInMs: number;
username: string;
userId: number;
rolId: number;
authenticated: boolean;
confirmed: boolean;
}
export interface MeResponse {
username: string;
userId: number;
rolId: number;
confirmed: boolean;
}
export interface ConfirmadoTF {
authenticated: boolean;
confirmed: boolean;
}
export interface Cabana {
id: number;
nombre: string;
telefono: string;
visible: boolean;
}
export interface Remate {
id: number;
nombre: string;
fecha: string;
fechaFin: string;
urlListaLotes?: string;
estado: string;
visible: boolean;
cabana: Cabana;
}
export interface Lote {
id: number;
nombre: string;
precio: number;
raza: string;
prelance: number;
puja: number;
estado: string;
visible: boolean;
video?: string;
numLote: number;
remate: Pick<Remate, 'id'> & Partial<Remate>;
cabana: Pick<Cabana, 'id'> & Partial<Cabana>;
}
export interface Puja {
id: number;
fecha: string;
monto: number;
visible: boolean;
usuario: Pick<Usuario, 'id' | 'username'> & Partial<Usuario>;
lote: Pick<Lote, 'id'> & Partial<Lote>;
cabana: Pick<Cabana, 'id'> & Partial<Cabana>;
}
export interface HistorialLote {
id: number;
monto: number;
fecha: string;
estado: string;
usuario: Usuario;
lote: Lote;
remate: Remate;
}
export interface PujaRequest {
usuarioId: number;
loteId: number;
remateId: number;
cabanaId: number;
monto: number;
fecha: string;
visible: boolean;
}
export interface PujaResponse {
pujaId: number;
loteId: number;
remateId: number;
usuarioId: number;
montoPujado: number;
fecha: string;
estado: string;
}
export interface ApiError {
error?: string;
message?: string;
}
export interface RegistrarUsuarioBody {
username: string;
password: string;
nombre: string;
ci: number;
celular: number;
rol: number;
}
export interface ActualizarUsuarioBody {
username?: string;
password?: string;
nombre?: string;
ci?: number;
celular?: number;
aprobado?: boolean;
visible?: boolean;
rol?: number;
}

24
src/utils/date.ts Normal file
View File

@@ -0,0 +1,24 @@
const locale = 'es-BO';
export function formatDateTime(iso: string | undefined | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(locale, {
dateStyle: 'short',
timeStyle: 'short',
});
}
export function toDatetimeLocalValue(iso: string | undefined | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function datetimeLocalToIso(value: string): string {
if (!value) return new Date().toISOString();
return new Date(value).toISOString();
}

25
src/utils/urls.ts Normal file
View File

@@ -0,0 +1,25 @@
export function getApiBase(): string {
const env = import.meta.env.VITE_API_URL as string | undefined;
return (env ?? '').replace(/\/$/, '');
}
export function apiUrl(path: string): string {
const base = getApiBase();
const normalized = path.startsWith('/') ? path : `/${path}`;
return base ? `${base}${normalized}` : normalized;
}
export function getWsBase(): string {
const api = getApiBase();
if (api) {
return api.replace(/^http/i, (m) => (m.toLowerCase() === 'https' ? 'wss' : 'ws'));
}
const { protocol, host } = window.location;
return `${protocol === 'https:' ? 'wss' : 'ws'}://${host}`;
}
export function wsUrl(path: string): string {
const base = getWsBase();
const normalized = path.startsWith('/') ? path : `/${path}`;
return `${base}${normalized}`;
}

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

21
tailwind.config.js Normal file
View File

@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
surface: {
DEFAULT: '#0f1419',
raised: '#1a2332',
border: '#2d3a4f',
},
accent: {
DEFAULT: '#3b82f6',
hover: '#2563eb',
muted: '#1e3a5f',
},
},
},
},
plugins: [],
};

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}

12
tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}

32
vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const apiTarget = env.VITE_API_URL || 'https://testapp.digitaltelecom.net';
return {
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: env.VITE_API_URL
? undefined
: {
'/api': { target: apiTarget, changeOrigin: true, secure: true },
'/auth': { target: apiTarget, changeOrigin: true, secure: true },
'/contador': { target: apiTarget, changeOrigin: true, secure: true },
'/ws': {
target: apiTarget.replace(/^http/i, 'ws'),
ws: true,
changeOrigin: true,
},
},
},
};
});