feat(docker): añadir Dockerfile para contenerizar la aplicación
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
3
.env.example
Normal 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
46
.gitignore
vendored
@@ -1,41 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
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
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
145
.next/types/cache-life.d.ts
vendored
Normal file
145
.next/types/cache-life.d.ts
vendored
Normal 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
57
.next/types/routes.d.ts
vendored
Normal 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
16
.next/types/validator.ts
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
# Ferco Admin (Vite + React 18)
|
||||
|
||||
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.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
- Stack: React 18, Vite 6, TypeScript, Tailwind CSS 3, React Router 6, TanStack Query.
|
||||
- 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`.
|
||||
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal 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
129
README.md
@@ -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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
bun install
|
||||
```
|
||||
|
||||
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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Desarrollo
|
||||
|
||||
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`
|
||||
|
||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal 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
30
docker/nginx.conf
Normal 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;
|
||||
}
|
||||
@@ -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
13
index.html
Normal 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>
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
43
package.json
43
package.json
@@ -1,30 +1,29 @@
|
||||
{
|
||||
"name": "panel-admin-remate",
|
||||
"version": "0.1.0",
|
||||
"name": "ferco-admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"tailwindcss": "^4"
|
||||
},
|
||||
"ignoreScripts": [
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
],
|
||||
"trustedDependencies": [
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
4
public/vite.svg
Normal file
4
public/vite.svg
Normal 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
50
src/App.tsx
Normal 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
17
src/api/cabanas.ts
Normal 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
93
src/api/client.ts
Normal 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
29
src/api/contador.ts
Normal 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
9
src/api/historial.ts
Normal 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
32
src/api/lotes.ts
Normal 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
31
src/api/pujas.ts
Normal 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
22
src/api/remates.ts
Normal 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
12
src/api/roles.ts
Normal 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
67
src/api/usuarios.ts
Normal 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 },
|
||||
),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
122
src/auth/AuthContext.tsx
Normal 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
21
src/auth/PrivateRoute.tsx
Normal 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 />;
|
||||
}
|
||||
45
src/components/ConfirmDialog.tsx
Normal file
45
src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/DataTable.tsx
Normal file
70
src/components/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/ErrorAlert.tsx
Normal file
24
src/components/ErrorAlert.tsx
Normal 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
13
src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/components/LoadingSpinner.tsx
Normal file
9
src/components/LoadingSpinner.tsx
Normal 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
50
src/components/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/PageHeader.tsx
Normal file
23
src/components/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/Sidebar.tsx
Normal file
55
src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/StatCard.tsx
Normal file
26
src/components/StatCard.tsx
Normal 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;
|
||||
}
|
||||
30
src/components/WsStatusBadge.tsx
Normal file
30
src/components/WsStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/form/Field.tsx
Normal file
52
src/components/form/Field.tsx
Normal 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
136
src/hooks/useContador.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
src/hooks/useEventosRemate.ts
Normal file
21
src/hooks/useEventosRemate.ts
Normal 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
74
src/hooks/useLoteSSE.ts
Normal 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
79
src/hooks/useWebSocket.ts
Normal 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
12
src/index.css
Normal 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
10
src/main.tsx
Normal 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
169
src/pages/Cabanas.tsx
Normal 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
97
src/pages/Dashboard.tsx
Normal 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
52
src/pages/Historial.tsx
Normal 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
269
src/pages/LiveRoom.tsx
Normal 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
70
src/pages/Login.tsx
Normal 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
276
src/pages/Lotes.tsx
Normal 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
178
src/pages/Pujas.tsx
Normal 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
313
src/pages/Remates.tsx
Normal 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
252
src/pages/Users.tsx
Normal 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
144
src/types/index.ts
Normal 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
24
src/utils/date.ts
Normal 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
25
src/utils/urls.ts
Normal 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
9
src/vite-env.d.ts
vendored
Normal 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
21
tailwind.config.js
Normal 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
22
tsconfig.json
Normal 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
12
tsconfig.node.json
Normal 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
32
vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user