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.
|
node_modules
|
||||||
|
dist
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.log
|
||||||
|
.env
|
||||||
# debug
|
.env.local
|
||||||
npm-debug.log*
|
.env.*.local
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|||||||
145
.next/types/cache-life.d.ts
vendored
Normal file
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 -->
|
# Ferco Admin (Vite + React 18)
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
- Stack: React 18, Vite 6, TypeScript, Tailwind CSS 3, React Router 6, TanStack Query.
|
||||||
<!-- END:nextjs-agent-rules -->
|
- Alias `@/` → `src/`.
|
||||||
|
- API base: `import.meta.env.VITE_API_URL` (vacío = mismo origen / proxy dev).
|
||||||
|
- Auth: JWT en `localStorage` key `ferco_admin_token`; cliente en `src/api/client.ts`.
|
||||||
|
|||||||
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
|
```bash
|
||||||
npm run dev
|
bun install
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Con npm:
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
## Variables de entorno
|
||||||
|
|
||||||
## Learn More
|
Copie `.env.example` a `.env`:
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
| Variable | Descripción |
|
||||||
|
|----------|-------------|
|
||||||
|
| `VITE_API_URL` | URL base del backend **sin** barra final. Ej: `https://testapp.digitaltelecom.net`. Si está **vacía**, las peticiones usan el mismo origen (recomendado en dev con el proxy de Vite). |
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
## Desarrollo
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
```bash
|
||||||
|
bun run dev
|
||||||
|
# o: npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
## Deploy on Vercel
|
Abre [http://localhost:5173](http://localhost:5173).
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
Sin `VITE_API_URL`, Vite hace proxy de `/api`, `/auth`, `/contador` y `/ws` hacia `https://testapp.digitaltelecom.net` (configurable en `vite.config.ts`).
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
## Producción
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
bun run preview
|
||||||
|
# o: npm run build && npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Despliegue: sirva la carpeta `dist/` en el mismo dominio que la API o configure CORS en el backend.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Imagen multi-etapa: **Bun** compila el frontend y **nginx** sirve los estáticos.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Con docker compose (recomendado)
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Panel en http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables en `docker-compose.yml` o en un `.env` junto al compose:
|
||||||
|
|
||||||
|
| Variable | Default | Descripción |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `VITE_API_URL` | `https://testapp.digitaltelecom.net` | URL del backend (se embebe en el build) |
|
||||||
|
| `APP_PORT` | `8080` | Puerto expuesto en el host |
|
||||||
|
|
||||||
|
Build manual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build \
|
||||||
|
--build-arg VITE_API_URL=https://testapp.digitaltelecom.net \
|
||||||
|
-t ferco-admin:latest .
|
||||||
|
|
||||||
|
docker run -d -p 8080:80 --name ferco-admin ferco-admin:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Nota:** `VITE_API_URL` se define en **tiempo de build**. Si cambia la API, reconstruya la imagen (`docker compose up -d --build`).
|
||||||
|
|
||||||
|
## Autenticación
|
||||||
|
|
||||||
|
- Login: `POST /api/usuarios/login` (email + contraseña).
|
||||||
|
- Token JWT en `localStorage` (`ferco_admin_token`).
|
||||||
|
- Rutas del panel protegidas; `401` cierra sesión y redirige a `/login`.
|
||||||
|
- Solo usuarios **aprobados** (`confirmed: true`) pueden entrar.
|
||||||
|
|
||||||
|
## Módulos
|
||||||
|
|
||||||
|
| Ruta | Función |
|
||||||
|
|------|---------|
|
||||||
|
| `/` | Dashboard con totales y accesos rápidos |
|
||||||
|
| `/usuarios` | Listado, aprobación de pendientes, edición, eliminación |
|
||||||
|
| `/cabanas` | CRUD cabañas |
|
||||||
|
| `/remates` | CRUD remates, finalizar remate, indicador WS eventos |
|
||||||
|
| `/lotes` | CRUD lotes, suscripción SSE opcional por lote |
|
||||||
|
| `/pujas` | Listado, filtros, informes por remate |
|
||||||
|
| `/en-vivo` | Sala operador: contador WS/REST, reset, corregir, FIN_REMATE, pujas en vivo |
|
||||||
|
| `/historial` | `GET /api/HistorialPujas` |
|
||||||
|
|
||||||
|
## Tiempo real
|
||||||
|
|
||||||
|
- **Contador:** `wss://…/ws/puja/{remateId}/{loteId}` + REST `/contador/*`
|
||||||
|
- **Eventos remate:** `wss://…/ws/eventos/{remateId}` → mensaje `FIN_REMATE`
|
||||||
|
- **SSE lotes:** `GET /api/webflux/lotes/stream/{loteId}` con header `Authorization: Bearer …`
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
api/ Cliente HTTP y endpoints
|
||||||
|
auth/ AuthContext, PrivateRoute
|
||||||
|
hooks/ useWebSocket, useContador, useEventosRemate, useLoteSSE
|
||||||
|
pages/ Pantallas del panel
|
||||||
|
components/ Layout, Sidebar, DataTable, Modal, etc.
|
||||||
|
types/ Interfaces TypeScript
|
||||||
|
utils/ URLs, fechas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend de referencia
|
||||||
|
|
||||||
|
Producción: `https://testapp.digitaltelecom.net`
|
||||||
|
|||||||
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",
|
"name": "ferco-admin",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview",
|
||||||
"lint": "eslint"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.7",
|
"@tanstack/react-query": "^5.62.8",
|
||||||
"react": "19.2.4",
|
"react": "^18.3.1",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@types/node": "^22.10.2",
|
||||||
"eslint": "^9",
|
"@types/react": "^18.3.12",
|
||||||
"eslint-config-next": "16.2.7",
|
"@types/react-dom": "^18.3.1",
|
||||||
"tailwindcss": "^4"
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
},
|
"autoprefixer": "^10.4.20",
|
||||||
"ignoreScripts": [
|
"postcss": "^8.4.49",
|
||||||
"sharp",
|
"tailwindcss": "^3.4.16",
|
||||||
"unrs-resolver"
|
"typescript": "^5.7.2",
|
||||||
],
|
"vite": "^6.0.3"
|
||||||
"trustedDependencies": [
|
}
|
||||||
"sharp",
|
|
||||||
"unrs-resolver"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
postcss.config.js
Normal file
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