35 Commits

Author SHA1 Message Date
jpbeldomenico f1ef9da857 fix(ci): construir antes de bajar el servicio
Revisión con IA / ai-review (pull_request) Failing after 24s
El despliegue hacia "docker compose down" y recien despues
"up -d --build". Si la construccion fallaba, el taller quedaba caido hasta
que alguien lo notara: paso el 03/08/2026, cuando npm no pudo bajar las
dependencias y el sitio estuvo abajo sin que ningun paso lo avisara.

Ahora la imagen se construye primero y solo despues se recrean los
contenedores, sin bajar nada de antemano. Si la construccion falla, lo que
estaba corriendo sigue en pie.

Se agrega ademas una verificacion final: si al terminar no hay contenedores
corriendo, el despliegue falla y muestra los registros, en lugar de dar por
bueno un servicio caido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:49:17 -03:00
jpbeldomenico 8d1b5da448 Merge pull request 'ci: revisar los pull requests con IA' (#1) from feat/ai-review into main
Deploy / deploy (push) Failing after 1m31s
2026-08-03 19:27:55 -03:00
jpbeldomenico 5e027d2c8c feat(ci): revisar los pull requests con IA
Revisión con IA / ai-review (pull_request) Successful in 1m58s
Replica en este repositorio la revision automatica que ya corre en
prode_app: al abrir, reabrir o actualizar un pull request, un modelo lee el
diff completo y publica comentarios pegados a cada linea mas un resumen,
firmados por el usuario ai-bot.

El modelo es gratuito y cada revision cuesta dos pedidos, porque manda todos
los archivos juntos en lugar de uno por archivo.

Los secretos se leen de Infisical al arrancar el job, asi que en el
repositorio solo quedan las credenciales de la identidad de maquina.

El prompt esta escrito sobre este proyecto en particular: turnos que se
pisan, husos horarios, autorizacion distinta para admin, jefe y cliente,
magic links tratados como credenciales, migraciones que acompañen a
schema.prisma y datos personales que no terminen en los registros.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:55:10 -03:00
Beldor 0792ac3767 Add jefe mecánico to client booking flow
Deploy / deploy (push) Successful in 6m52s
Cliente now picks a jefe mecánico after choosing the service so the
calendar shows that specific jefe's availability. Each jefe has its own
configurable parallel slot capacity (replacing the global SLOTS_PARALELOS
= 5). Admins manage jefes from /admin/jefes; jefes log in to /jefe to
see their daily agenda.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-29 22:28:44 -03:00
Beldor f79e5ce75c Lighten admin page titles and loading text over background image
Deploy / deploy (push) Successful in 4m2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:24:10 -03:00
Beldor a1eedcf8f5 Add dark blur background to admin nav on mobile
Deploy / deploy (push) Successful in 4m0s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:16:07 -03:00
Beldor 7cc6456f97 Remove non-functional headers; lighten text on background image
Deploy / deploy (push) Successful in 4m2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 16:59:02 -03:00
Beldor 457db923d2 feat: agregar logo Taller MG como fondo y favicon
Deploy / deploy (push) Successful in 4m35s
- Background fijo con tallermg.jpeg + overlay rgba(0,0,0,0.35) en globals.css
- Favicon actualizado usando src/app/icon.jpeg (convención Next.js App Router)
- Eliminado bg-gray-50 de layouts para que el fondo sea visible
- Título del sitio actualizado a "Taller MG"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:54:02 -03:00
Beldor cc299cdc11 fix: validar km máximo 1M y mostrar error si falla la reserva
Deploy / deploy (push) Successful in 4m39s
Si alguien ingresaba un km fuera del rango integer de Postgres la app
crasheaba silenciosamente — el cliente no veía ningún mensaje. Se agrega
validación de rango (1–1.000.000) en frontend y backend, y se muestra
el error de la API debajo del botón de confirmar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 11:08:11 -03:00
Beldor 0b769805f9 feat: validar que kilometraje sea número entero positivo
Deploy / deploy (push) Successful in 4m1s
Muestra error inline si el cliente ingresa letras u otros caracteres,
y bloquea el botón de confirmar hasta que el valor sea válido.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:52:31 -03:00
Beldor 221aace9f1 feat: agregar IKA-Renault con modelos Torino, Rambler, Jeep, Carabela
Deploy / deploy (push) Successful in 3m59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:47:51 -03:00
Beldor 0497e936a4 feat: calendario mensual para reservar turnos
Deploy / deploy (push) Successful in 4m3s
- Reemplaza lista de slots por calendario cuadrado mensual
- Sábados y domingos grisados, días sin slots deshabilitados
- Click en día hábil abre modal con horarios disponibles
- Slot dot indicator en días con turnos disponibles
- Amplía búsqueda a 60 días / 500 slots para cubrir todo el mes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:33:29 -03:00
Beldor 4a6023f9f3 feat: historial muestra todos los turnos del auto con estado, km y comentario
Deploy / deploy (push) Successful in 3m52s
Reemplaza HistorialServicio por Turno en ambas APIs y frontends (cliente y admin).
Muestra: servicio, fecha, horario, estado (badge), km al ingreso, observaciones del jefe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:19:53 -03:00
Beldor 112f4a07bb fix: quitar header azul de las tarjetas del historial
Deploy / deploy (push) Successful in 3m58s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:12:32 -03:00
Beldor 7b3ce1ab82 fix: google sheets append con A1 + INSERT_ROWS para evitar desplazamiento de columna
Deploy / deploy (push) Successful in 4m2s
El rango A:N confundía a la API cuando había datos manuales en K/L (GASTOS/VARIOS),
haciendo que la nueva fila empezara en columna K en vez de A.
INSERT_ROWS siempre inserta fila nueva independiente del estado del sheet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:53:52 -03:00
Beldor 8167f6c901 fix: scheduling en UTC explícito para Argentina (UTC-3), sin depender del TZ del container
Deploy / deploy (push) Successful in 3m57s
getHours() en Docker devuelve UTC → slots aparecían 3hs antes (5:30 en vez de 8:30).
Ahora usa getUTCHours/getUTCMinutes + offset -180min en todas las comparaciones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:42:43 -03:00
Beldor e0779bf2d4 fix: agregar GMAIL, Google Sheets y JWT al environment del docker-compose
Deploy / deploy (push) Successful in 3m55s
Variables faltantes causaban EAUTH en el login del cliente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:30:57 -03:00
Beldor d6c16cb30e ci: fix deploy — git fetch + reset --hard origin/main antes del rebuild
Deploy / deploy (push) Successful in 6m49s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:18:56 -03:00
Beldor 146ef98563 ci: usar label 'taller' en runner para evitar conflicto con runner de inventario
Deploy / deploy (push) Failing after 0s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:04:51 -03:00
Beldor c2b0e39b06 feat: comentario al completar turno, Google Sheets corregido, scheduling 8:30-17 5 slots, mejoras responsive
Deploy / deploy (push) Failing after 12m35s
- Agrega campo comentario opcional al marcar turno COMPLETADO (textarea en UI, guardado en DB, escrito en col N del sheet)
- Corrige mapeo Google Sheets: email→I, telefono→J, vacíos en K-M, comentario→N
- Actualiza scheduling: inicio 8:30, fin 17:00, máx 5 turnos simultáneos
- entrypoint.sh: migrate deploy → db push para aplicar cambios de schema automáticamente
- Historial cliente: tarjetas visuales con header, fecha y observaciones destacadas
- Dashboard/turnos cliente: mejoras responsive mobile
- Fix display duración: 0.5 hs → "30 min"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:57:27 -03:00
Beldor e556e2f16b feat: integración Google Sheets al completar turno
Deploy / deploy (push) Failing after 11m51s
Cuando el admin marca un turno como COMPLETADO escribe una fila
en el sheet con patente, km, auto, cliente, fecha y trabajo.
Auth via service account con JWT nativo (sin googleapis).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 17:38:14 -03:00
Beldor 4d259558c6 feat: agrega km y chasis al turno/auto, combobox de marcas y modelos
Deploy / deploy (push) Failing after 13m41s
- Turno.kilometros (requerido al reservar)
- Auto.nroChasis (opcional)
- Combobox con 60 marcas mundiales y modelos precargados
- Modelo se habilita al seleccionar marca

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 16:01:19 -03:00
Beldor b6705239a1 fix(cliente): cargar servicios desde endpoint de cliente en vez de admin
Deploy / deploy (push) Failing after 33h14m30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 23:20:25 -03:00
Beldor 3724669ace feat(admin): buscar clientes por patente en lugar de email
Deploy / deploy (push) Failing after 13m38s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 22:51:05 -03:00
Beldor 4554543260 fix: migrate registro email from Resend to Nodemailer+Gmail
Deploy / deploy (push) Successful in 3m6s
2026-06-19 14:55:44 -03:00
Beldor bf43c54126 ci: retry after fixing file permissions
Deploy / deploy (push) Successful in 5m16s
2026-06-19 14:38:11 -03:00
Beldor d69ac3f084 ci: add git reset --hard to ensure clean working tree on deploy
Deploy / deploy (push) Failing after 1s
2026-06-19 14:37:23 -03:00
Beldor 1e5b8911e7 ci: retry after git repo reinit
Deploy / deploy (push) Failing after 28s
2026-06-19 14:35:42 -03:00
Beldor 05dd9a4847 ci: retry after fixing git permissions
Deploy / deploy (push) Failing after 1s
2026-06-19 14:34:45 -03:00
Beldor 9253f00062 ci: fix git remote to use Gitea IP
Deploy / deploy (push) Failing after 0s
2026-06-19 14:33:27 -03:00
Beldor 50a41ce0dc fix: add resend dependency to package.json
Deploy / deploy (push) Failing after 0s
2026-06-19 14:32:27 -03:00
Beldor 346e15a6e1 ci: test native runner
Deploy / deploy (push) Failing after 0s
2026-06-19 14:25:49 -03:00
Beldor a5b684efe6 ci: fix safe.directory for git pull in runner
Deploy / deploy (push) Failing after 1s
2026-06-19 14:21:04 -03:00
Beldor 5f190b9c50 ci: add Gitea Actions deploy workflow
Deploy / deploy (push) Failing after 0s
2026-06-19 13:59:19 -03:00
Beldor 011cdb0323 feat: migrar emails de Resend a Nodemailer + Gmail SMTP
Reemplaza Resend por nodemailer con Gmail SMTP. El to: ahora usa
el email real del cliente en lugar del hardcodeado de sandbox.
Variables de entorno: GMAIL_USER y GMAIL_APP_PASSWORD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:03:13 -03:00
53 changed files with 2344 additions and 433 deletions
+60
View File
@@ -0,0 +1,60 @@
# Configuración de la revisión con IA (https://github.com/Nikita-Filonov/ai-review)
#
# Acá va sólo lo estático. Los tokens y el contexto del pull request llegan por
# variables de entorno desde .gitea/workflows/ai-review.yml, con la convención
# de doble guión bajo que usa la herramienta (VCS__PIPELINE__OWNER, etc.).
# Ojo: la sintaxis ${VARIABLE} dentro de este archivo NO se resuelve.
llm:
provider: OPENROUTER
meta:
# Modelo sin costo, 1M de contexto.
model: nvidia/nemotron-3-ultra-550b-a55b:free
max_tokens: 8000
temperature: 0.2
# Viaja como cabecera HTTP y se codifica en ASCII: sin acentos ni enies.
title: "Revision IA Taller"
http_client:
timeout: 180
api_url: https://openrouter.ai/api/v1
# --- Si este modelo deja de estar disponible ---
# El catalogo gratuito de OpenRouter rota. Si el job falla con 404 de modelo,
# listar los vigentes y reemplazar la clave model:
# curl -s https://openrouter.ai/api/v1/models | grep -o '"id":"[^"]*:free"'
# Alternativas verificadas el 03/08/2026:
# cohere/north-mini-code:free
# nvidia/nemotron-3-super-120b-a12b:free
core:
# El plan gratuito de OpenRouter admite 20 pedidos por minuto.
concurrency: 3
review:
mode: FULL_FILE_DIFF
ignore_pure_renames: true
max_context_comments: 15
max_inline_comments: 10
ignore_changes:
- "package-lock.json"
- "*.lock"
- "*.min.js"
- "*.min.css"
- "*.svg"
- "*.png"
- "*.jpg"
- "*.jpeg"
- "*.ico"
- "*.woff*"
- "public/**"
- "tsconfig.tsbuildinfo"
prompt:
context_prompt_files:
- ./.gitea/ai-review/context.md
summary_prompt_files:
- ./.gitea/ai-review/summary.md
+2 -1
View File
@@ -1,5 +1,6 @@
DATABASE_URL="postgresql://taller:taller123@localhost:5432/taller_turnos" DATABASE_URL="postgresql://taller:taller123@localhost:5432/taller_turnos"
NEXTAUTH_SECRET="cambia-esto-por-un-secret-seguro" NEXTAUTH_SECRET="cambia-esto-por-un-secret-seguro"
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
RESEND_API_KEY="re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" GMAIL_USER="tu-email@gmail.com"
GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
MAGIC_LINK_BASE_URL="http://localhost:3000" MAGIC_LINK_BASE_URL="http://localhost:3000"
+68
View File
@@ -0,0 +1,68 @@
# Revisión de código — Taller Automotriz
Escribí siempre en español rioplatense, directo y sin rodeos.
Recibís **todos los archivos cambiados del pull request juntos**, no uno por
vez. Aprovechalo: además de los errores dentro de cada archivo, buscá los que
sólo se ven mirando el conjunto.
## Contexto del proyecto
Sistema de turnos para un taller mecánico. Next.js (App Router) con TypeScript,
Prisma sobre PostgreSQL:
- `src/app/api` — endpoints.
- `src/app/{admin,jefe,cliente}` — las tres áreas, con permisos distintos.
- `src/lib` — la lógica que importa: `scheduling.ts` y `turnos.ts` (slots y
reservas), `whatsapp-bot.ts` (bot de reservas), `magic-link.ts` y
`auth-admin.ts` (autenticación), `prisma.ts` (cliente).
- `prisma/schema.prisma` — modelo de datos: `Cliente`, `Auto`, `Turno`,
`TipoServicio`, `JefeMecanico`, `HistorialServicio`, `Admin`, `MagicToken`,
`ConversacionWhatsapp`.
## Qué mirar con prioridad
1. **Turnos y disponibilidad**: es el corazón del sistema. Reservas que se
pisan, husos horarios, slots que se calculan distinto en dos lugares,
condiciones de carrera entre dos clientes reservando el mismo horario,
estados de `Turno` que cambian sin validar la transición.
2. **Autorización por área**: `admin`, `jefe` y `cliente` ven cosas distintas.
Un endpoint que no verifica el rol en el backend, o que confía en lo que
manda el cliente, es un hallazgo grave. Los magic links son credenciales:
token que no expira, que se reutiliza o que se registra en un log.
3. **Bugs reales**: promesas sin `await`, `null`/`undefined` sin contemplar,
errores tragados, casos borde sin cubrir.
4. **Prisma**: migraciones que no acompañan al cambio de `schema.prisma`,
consultas dentro de un bucle (N+1), operaciones que deberían ir en una
transacción y no lo están.
5. **Coherencia entre archivos**: un tipo que cambia en `src/lib` y deja
desactualizado a quien lo consume, lógica de negocio duplicada entre un
endpoint y el bot de WhatsApp en vez de vivir en `src/lib`.
## Cuidados propios de este proyecto
- Los datos de clientes (teléfono, email, patente) son personales: no deben
terminar en logs ni en respuestas de error.
- El bot de WhatsApp responde a mensajes de afuera: todo lo que llega por ahí
es entrada no confiable y hay que validarlo.
- Nunca claves ni tokens en el código, los documentos o los mensajes.
## Antes de afirmar
Si tu observación depende de cómo se comporta una función del lenguaje, de
Next.js o de Prisma, verificá que estás seguro. Si no lo estás, plantealo como
pregunta ("¿confirmaste que X hace Y?") en vez de afirmarlo. Un señalamiento
correcto apoyado en una premisa falsa hace perder tiempo y resta credibilidad al
resto de la revisión.
## Qué no comentar
- Formato, comillas, punto y coma o largo de línea: de eso se ocupa el linter.
- Preferencias de estilo sin impacto en el comportamiento.
- Renombres de variables porque te suenan mejor.
- Reescrituras completas de un archivo que ya funciona.
- Elogios o comentarios que sólo describen lo que hace el código.
Como ves muchos archivos de una sola vez, resistí la tentación de comentar en
todos para parecer completo. Si de veinte archivos sólo tres tienen algo que
decir, comentá esos tres. Un comentario ruidoso cuesta más que uno faltante.
+23
View File
@@ -0,0 +1,23 @@
# Resumen del pull request — J2 ProDE
Escribí en español rioplatense. Sé breve: quien lee quiere decidir si aprobar o
no, no leer una redacción.
Estructura:
1. **Qué cambia** — dos o tres oraciones sobre la intención del cambio, no un
listado de archivos tocados.
2. **Riesgos** — sólo si los hay de verdad. Priorizá: violaciones de las
invariantes del proyecto, cambios de contrato en `packages/contracts`,
migraciones de PostgreSQL, autorización, y todo lo que pueda romper un
despliegue en curso.
3. **Antes de aprobar** — verificaciones concretas que convenga correr a mano si
el gate (`pnpm check`) no las cubre.
Reglas:
- Si no hay riesgos, decilo en una línea y terminá.
- No repitas el diff ni parafrasees cada archivo.
- No felicites al autor.
- Ante la duda de si algo es un problema, decilo como duda y explicá qué
verificar, sin afirmar que está roto.
+203
View File
@@ -0,0 +1,203 @@
name: Revisión con IA
on:
# Incluimos "synchronize" desde que la revision pasó al modo por contexto:
# antes costaba un pedido por archivo (unos veinte por revision) y revisar
# cada push era inviable contra el limite diario; ahora cuesta dos, asi que
# conviene que el pull request se revise de nuevo al corregirlo. Ademas el
# estado del check queda al dia: sin esto un pull request arreglado seguia
# figurando en rojo por la corrida vieja.
pull_request:
types: [opened, reopened, synchronize]
workflow_dispatch:
inputs:
pull-request-number:
description: "Número de PR a revisar"
required: true
type: string
review-command:
description: "Modo de revisión"
required: false
default: auto
type: choice
options:
# Lo mismo que hace el disparo automático: contexto + resumen,
# dos pedidos al modelo en total.
- auto
# Un pedido por archivo cambiado. Más minucioso y mucho más caro
# en cuota: con 20 archivos son 20 pedidos.
- run-inline
- run-context
- run-summary
- clear-inline
- clear-summary
jobs:
ai-review:
runs-on: ai-review
timeout-minutes: 20
steps:
- name: Descargar el código
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Preparar Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Instalar la herramienta de revisión
run: pip install xai-review==0.71.0
- name: Leer los secretos de Infisical
env:
INFISICAL_URL: ${{ vars.INFISICAL_URL }}
INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}
INFISICAL_PROJECT_ID: ${{ vars.INFISICAL_PROJECT_ID }}
INFISICAL_ENV: ${{ vars.INFISICAL_ENV }}
INFISICAL_PATH: ${{ vars.INFISICAL_PATH }}
run: |
set -eu
# Infisical vive en la misma red docker que este job, asi que le
# hablamos por DNS interno sin salir a Internet.
: "${INFISICAL_URL:=http://infisical:8080}"
: "${INFISICAL_PROJECT_ID:=4db7af6a-1f7a-445d-a5f3-fddb9e64b614}"
: "${INFISICAL_ENV:=prod}"
: "${INFISICAL_PATH:=/gitea-ai-review}"
export INFISICAL_URL INFISICAL_PROJECT_ID INFISICAL_ENV INFISICAL_PATH
python3 - <<'PY'
import json, os, sys, time, urllib.error, urllib.parse, urllib.request
base = os.environ["INFISICAL_URL"].rstrip("/")
INTENTOS = 3
ESPERA = 5
def diagnosticar(error):
"""Devuelve (mensaje, si_conviene_reintentar)."""
if isinstance(error, urllib.error.HTTPError):
motivos = {
400: "el pedido fue rechazado; revisar el identificador del proyecto",
401: "credenciales rechazadas; revisar INFISICAL_CLIENT_ID y INFISICAL_CLIENT_SECRET",
403: "la identidad de maquina no tiene permiso sobre el proyecto",
404: "no existe el proyecto, el entorno o la ruta indicada",
429: "demasiados pedidos a Infisical",
}
motivo = motivos.get(error.code, "respuesta inesperada del servicio")
# Reintentar solo lo transitorio: un 401 no se arregla esperando.
return f"HTTP {error.code} — {motivo}", error.code >= 500 or error.code == 429
if isinstance(error, urllib.error.URLError):
return f"no se pudo contactar a {base} ({error.reason})", True
return f"error inesperado: {error}", False
def call(path, data=None, token=None):
ultimo = None
for intento in range(1, INTENTOS + 1):
req = urllib.request.Request(base + path)
if token:
req.add_header("Authorization", "Bearer " + token)
if data is not None:
req.add_header("Content-Type", "application/json")
req.data = json.dumps(data).encode()
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except Exception as exc: # noqa: BLE001 - se clasifica abajo
mensaje, reintentable = diagnosticar(exc)
ultimo = mensaje
if not reintentable or intento == INTENTOS:
break
print(f"Infisical: {mensaje}. Reintento {intento}/{INTENTOS - 1} en {ESPERA}s.",
file=sys.stderr)
time.sleep(ESPERA)
print(f"No se pudieron leer los secretos de Infisical: {ultimo}", file=sys.stderr)
raise SystemExit(1)
auth = call("/api/v1/auth/universal-auth/login", {
"clientId": os.environ["INFISICAL_CLIENT_ID"],
"clientSecret": os.environ["INFISICAL_CLIENT_SECRET"],
})
query = urllib.parse.urlencode({
"workspaceId": os.environ["INFISICAL_PROJECT_ID"],
"environment": os.environ["INFISICAL_ENV"],
"secretPath": os.environ["INFISICAL_PATH"],
})
secrets = call("/api/v3/secrets/raw?" + query, token=auth["accessToken"])
wanted = {"OPENROUTER_API_KEY": "LLM__HTTP_CLIENT__API_TOKEN",
"AI_REVIEW_GITEA_TOKEN": "VCS__HTTP_CLIENT__API_TOKEN"}
found = {s["secretKey"]: s["secretValue"] for s in secrets["secrets"]}
faltan = [k for k in wanted if not found.get(k)]
if faltan:
print("Faltan secretos en Infisical: " + ", ".join(faltan), file=sys.stderr)
raise SystemExit(1)
with open(os.environ["GITHUB_ENV"], "a") as env_file:
for clave, variable in wanted.items():
valor = found[clave]
# Que el runner lo tache si alguna vez se imprime.
print(f"::add-mask::{valor}")
env_file.write(f"{variable}={valor}\n")
print(f"Secretos leidos de Infisical: {', '.join(wanted)}")
PY
- name: Revisar el pull request
env:
VCS__PROVIDER: GITEA
VCS__PIPELINE__OWNER: ${{ gitea.repository_owner }}
VCS__PIPELINE__REPO: ${{ gitea.event.repository.name }}
# El job corre en la red "proxy" del runner, la misma del contenedor
# gitea: hablamos con la API por DNS interno en vez de salir a
# Internet para volver por el proxy. Se puede sobrescribir con la
# variable de repositorio GITEA_API_URL.
GITEA_API_URL: ${{ vars.GITEA_API_URL }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
PR_NUMBER_INPUT: ${{ inputs.pull-request-number }}
REVIEW_COMMAND: ${{ inputs.review-command }}
run: |
set -eu
: "${GITEA_API_URL:=http://gitea:3000/api/v1}"
: "${PR_NUMBER:=${PR_NUMBER_INPUT}}"
: "${REVIEW_COMMAND:=auto}"
if [ -z "${PR_NUMBER}" ]; then
echo "No hay número de pull request para revisar"
exit 1
fi
export VCS__HTTP_CLIENT__API_URL="${GITEA_API_URL}"
export VCS__PIPELINE__PULL_NUMBER="${PR_NUMBER}"
# xai-review termina con codigo 0 aunque falle la publicacion de los
# comentarios: imprime el traceback y saluda con
# "AI review completed successfully!". Sin esto el job quedaria en
# verde sin haber revisado nada, que es peor que fallar.
revisar() {
echo "== ai-review $1"
if ! ai-review "$1" 2>&1 | tee "/tmp/ai-review-$1.log"; then
return 1
fi
if grep -qiE 'Traceback \(most recent call last\)|HTTPClientError|Error: ' "/tmp/ai-review-$1.log"; then
echo "La revisión reportó errores en '$1' pese a terminar sin código de error." >&2
return 1
fi
}
# Para depurar la configuración existe "ai-review show-config", pero
# imprime los tokens resueltos: no correrlo dentro del pipeline.
if [ "${REVIEW_COMMAND}" = "auto" ]; then
# La revisión por contexto manda todos los archivos en un único
# pedido y aun así deja los comentarios pegados a cada línea, así
# que el pull request completo cuesta dos pedidos al modelo.
revisar run-context
revisar run-summary
else
revisar "${REVIEW_COMMAND}"
fi
+42
View File
@@ -0,0 +1,42 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: taller
steps:
- name: Pull y rebuild
run: |
set -eu
git config --global --add safe.directory /home/proyecto/taller_automotriz
cd /home/proyecto/taller_automotriz
git fetch origin
git reset --hard origin/main
# Construir ANTES de tocar lo que esta corriendo. Antes este paso
# hacia "docker compose down" y recien despues "up --build": si la
# construccion fallaba (por ejemplo, npm sin poder bajar las
# dependencias), el taller quedaba caido hasta que alguien lo
# notara. Pasó el 03/08/2026.
docker compose build
# Con la imagen nueva ya lista, "up -d" recrea los contenedores que
# cambiaron. No hace falta bajar nada de antemano.
docker compose up -d --no-build --remove-orphans
- name: Verificar que quedo arriba
run: |
set -eu
cd /home/proyecto/taller_automotriz
docker compose ps
# Si el contenedor no esta corriendo, el despliegue fallo aunque los
# comandos anteriores hayan salido bien.
if [ -z "$(docker compose ps --quiet --status running)" ]; then
echo "El despliegue termino sin contenedores corriendo"
docker compose logs --tail 50
exit 1
fi
+5
View File
@@ -28,6 +28,11 @@ services:
NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_URL: ${NEXTAUTH_URL}
RESEND_API_KEY: ${RESEND_API_KEY} RESEND_API_KEY: ${RESEND_API_KEY}
MAGIC_LINK_BASE_URL: ${MAGIC_LINK_BASE_URL} MAGIC_LINK_BASE_URL: ${MAGIC_LINK_BASE_URL}
GMAIL_USER: ${GMAIL_USER}
GMAIL_APP_PASSWORD: ${GMAIL_APP_PASSWORD}
GOOGLE_CLIENT_EMAIL: ${GOOGLE_CLIENT_EMAIL}
GOOGLE_SHEET_ID: ${GOOGLE_SHEET_ID}
GOOGLE_PRIVATE_KEY: ${GOOGLE_PRIVATE_KEY}
ports: ports:
- "3004:3000" - "3004:3000"
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
set -e set -e
echo "Corriendo migraciones..." echo "Corriendo migraciones..."
node node_modules/prisma/build/index.js migrate deploy node node_modules/prisma/build/index.js db push --accept-data-loss
echo "Migraciones OK. Iniciando app..." echo "Migraciones OK. Iniciando app..."
exec node_modules/.bin/next start exec node_modules/.bin/next start
+344 -52
View File
@@ -10,11 +10,14 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@types/nodemailer": "^8.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"googleapis": "^173.0.0",
"jose": "^6.2.3", "jose": "^6.2.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"next": "14.2.35", "next": "14.2.35",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"nodemailer": "^6.10.1",
"prisma": "^7.8.0", "prisma": "^7.8.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
@@ -708,7 +711,6 @@
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^5.1.2", "string-width": "^5.1.2",
@@ -726,7 +728,6 @@
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -739,7 +740,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.2.2" "ansi-regex": "^6.2.2"
@@ -1036,7 +1036,6 @@
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
@@ -1502,6 +1501,15 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -2164,6 +2172,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2185,7 +2202,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -2195,7 +2211,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -2478,7 +2493,26 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true, "license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcryptjs": { "node_modules/bcryptjs": {
@@ -2496,6 +2530,15 @@
"integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2638,7 +2681,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -2652,7 +2694,6 @@
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -2782,7 +2823,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -2795,7 +2835,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@@ -2870,6 +2909,15 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/data-view-buffer": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -2928,7 +2976,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -3058,7 +3105,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
@@ -3073,7 +3119,6 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": { "node_modules/ecdsa-sig-formatter": {
@@ -3099,7 +3144,6 @@
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/empathic": { "node_modules/empathic": {
@@ -3215,7 +3259,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -3225,7 +3268,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -3263,7 +3305,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -3812,6 +3853,12 @@
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-check": { "node_modules/fast-check": {
"version": "3.23.2", "version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
@@ -3916,6 +3963,29 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -4013,6 +4083,18 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -4039,7 +4121,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -4079,6 +4160,34 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/gaxios": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz",
"integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/generate-function": { "node_modules/generate-function": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@@ -4102,7 +4211,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -4133,7 +4241,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dunder-proto": "^1.0.1", "dunder-proto": "^1.0.1",
@@ -4188,7 +4295,6 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
"integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"foreground-child": "^3.1.0", "foreground-child": "^3.1.0",
@@ -4224,7 +4330,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -4234,7 +4339,6 @@
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.2" "brace-expansion": "^2.0.2"
@@ -4279,11 +4383,114 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/google-auth-library": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz",
"integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis": {
"version": "173.0.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-173.0.0.tgz",
"integrity": "sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.2.0",
"googleapis-common": "^8.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.2.tgz",
"integrity": "sha512-5MXeQzIZaqCH7B+HJWqhQm946VARpZep6acbWSr/fcgF2cQANq7allgX+i/G0EqF0WyUxB277gtWMzRYHMl9tg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"gaxios": "7.1.3",
"google-auth-library": "10.5.0",
"google-logging-utils": "1.1.3",
"qs": "^6.7.0",
"url-template": "^2.0.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/googleapis-common/node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common/node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common/node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4317,6 +4524,19 @@
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -4373,7 +4593,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4402,7 +4621,6 @@
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -4426,6 +4644,19 @@
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -4717,7 +4948,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -5005,7 +5235,6 @@
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
"integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/cliui": "^8.0.2" "@isaacs/cliui": "^8.0.2"
@@ -5068,6 +5297,15 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -5312,7 +5550,6 @@
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/lru.min": { "node_modules/lru.min": {
@@ -5334,7 +5571,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5391,7 +5627,6 @@
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -5593,6 +5828,26 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-exports-info": { "node_modules/node-exports-info": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -5622,6 +5877,33 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -5665,7 +5947,6 @@
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5911,7 +6192,6 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
@@ -6463,6 +6743,21 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6940,7 +7235,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -6960,7 +7254,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -6977,7 +7270,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -6996,7 +7288,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -7100,7 +7391,6 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"eastasianwidth": "^0.2.0", "eastasianwidth": "^0.2.0",
@@ -7119,7 +7409,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -7134,14 +7423,12 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string-width/node_modules/ansi-regex": { "node_modules/string-width/node_modules/ansi-regex": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -7154,7 +7441,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.2.2" "ansi-regex": "^6.2.2"
@@ -7284,7 +7570,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -7298,7 +7583,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -7802,6 +8086,12 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/url-template": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -7823,6 +8113,15 @@
} }
} }
}, },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7941,7 +8240,6 @@
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^6.1.0", "ansi-styles": "^6.1.0",
@@ -7960,7 +8258,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
@@ -7978,14 +8275,12 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/wrap-ansi-cjs/node_modules/string-width": { "node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -8000,7 +8295,6 @@
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -8013,7 +8307,6 @@
"version": "6.2.3", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -8026,7 +8319,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.2.2" "ansi-regex": "^6.2.2"
+3
View File
@@ -18,11 +18,14 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@types/nodemailer": "^8.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"googleapis": "^173.0.0",
"jose": "^6.2.3", "jose": "^6.2.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"next": "14.2.35", "next": "14.2.35",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"nodemailer": "^6.10.1",
"prisma": "^7.8.0", "prisma": "^7.8.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
+17
View File
@@ -26,6 +26,7 @@ model Auto {
marca String marca String
modelo String modelo String
anio Int anio Int
nroChasis String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
cliente Cliente @relation(fields: [clienteId], references: [id]) cliente Cliente @relation(fields: [clienteId], references: [id])
@@ -48,14 +49,30 @@ model Turno {
clienteId String clienteId String
autoId String autoId String
tipoServicioId String tipoServicioId String
jefeMecanicoId String
fechaInicio DateTime fechaInicio DateTime
fechaFin DateTime fechaFin DateTime
kilometros Int
estado EstadoTurno @default(PENDIENTE) estado EstadoTurno @default(PENDIENTE)
comentario String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
cliente Cliente @relation(fields: [clienteId], references: [id]) cliente Cliente @relation(fields: [clienteId], references: [id])
auto Auto @relation(fields: [autoId], references: [id]) auto Auto @relation(fields: [autoId], references: [id])
tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id]) tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id])
jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id])
}
model JefeMecanico {
id String @id @default(cuid())
nombre String
email String @unique
passwordHash String
slotsParalelos Int @default(1)
activo Boolean @default(true)
createdAt DateTime @default(now())
turnos Turno[]
} }
model HistorialServicio { model HistorialServicio {
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+2 -2
View File
@@ -40,12 +40,12 @@ export default function ClientesPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<h1 className="text-2xl font-bold text-gray-800">Clientes</h1> <h1 className="text-2xl font-bold text-white">Clientes</h1>
<Card title="Buscar cliente"> <Card title="Buscar cliente">
<form onSubmit={buscar} className="flex gap-2"> <form onSubmit={buscar} className="flex gap-2">
<Input <Input
placeholder="Nombre, apellido o email..." placeholder="Nombre, apellido o patente..."
value={busqueda} value={busqueda}
onChange={(e) => setBusqueda(e.target.value)} onChange={(e) => setBusqueda(e.target.value)}
className="flex-1" className="flex-1"
+49 -7
View File
@@ -18,6 +18,8 @@ export default function AdminDashboard() {
const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10)); const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10));
const [turnos, setTurnos] = useState<Turno[]>([]); const [turnos, setTurnos] = useState<Turno[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [completando, setCompletando] = useState<Set<string>>(new Set());
const [comentarios, setComentarios] = useState<Record<string, string>>({});
useEffect(() => { cargar(); }, [fecha]); useEffect(() => { cargar(); }, [fecha]);
@@ -29,15 +31,26 @@ export default function AdminDashboard() {
setLoading(false); setLoading(false);
} }
async function cambiarEstado(id: string, estado: string) { async function cambiarEstado(id: string, estado: string, comentario?: string) {
await fetch(`/api/admin/turnos/${id}`, { await fetch(`/api/admin/turnos/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado }), body: JSON.stringify({ estado, comentario }),
}); });
setCompletando((prev) => { const s = new Set(prev); s.delete(id); return s; });
setComentarios((prev) => { const c = { ...prev }; delete c[id]; return c; });
cargar(); cargar();
} }
function iniciarCompletado(id: string) {
setCompletando((prev) => new Set(prev).add(id));
}
function cancelarCompletado(id: string) {
setCompletando((prev) => { const s = new Set(prev); s.delete(id); return s; });
setComentarios((prev) => { const c = { ...prev }; delete c[id]; return c; });
}
function formatHora(iso: string) { function formatHora(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" }); return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
} }
@@ -45,17 +58,17 @@ export default function AdminDashboard() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-gray-800">Turnos del día</h1> <h1 className="text-xl sm:text-2xl font-bold text-white">Turnos del día</h1>
<input <input
type="date" type="date"
value={fecha} value={fecha}
onChange={(e) => setFecha(e.target.value)} onChange={(e) => setFecha(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500" className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 w-full sm:w-auto"
/> />
</div> </div>
{loading ? ( {loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div> <div className="text-center py-10 text-gray-200">Cargando...</div>
) : turnos.length === 0 ? ( ) : turnos.length === 0 ? (
<Card> <Card>
<p className="text-center text-gray-400 py-4">No hay turnos para esta fecha.</p> <p className="text-center text-gray-400 py-4">No hay turnos para esta fecha.</p>
@@ -96,11 +109,40 @@ export default function AdminDashboard() {
</Button> </Button>
</div> </div>
)} )}
{t.estado === "CONFIRMADO" && (
<Button onClick={() => cambiarEstado(t.id, "COMPLETADO")} variant="secondary" className="text-sm"> {t.estado === "CONFIRMADO" && !completando.has(t.id) && (
<Button onClick={() => iniciarCompletado(t.id)} variant="secondary" className="text-sm">
✓ Marcar completado ✓ Marcar completado
</Button> </Button>
)} )}
{t.estado === "CONFIRMADO" && completando.has(t.id) && (
<div className="flex flex-col gap-2">
<textarea
value={comentarios[t.id] ?? ""}
onChange={(e) => setComentarios((prev) => ({ ...prev, [t.id]: e.target.value }))}
placeholder="Observaciones (opcional)..."
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
<div className="flex gap-2">
<Button
onClick={() => cambiarEstado(t.id, "COMPLETADO", comentarios[t.id])}
variant="secondary"
className="flex-1 text-sm"
>
✓ Confirmar completado
</Button>
<Button
variant="danger"
onClick={() => cancelarCompletado(t.id)}
className="flex-1 text-sm"
>
Cancelar
</Button>
</div>
</div>
)}
</div> </div>
</Card> </Card>
))} ))}
+41 -110
View File
@@ -2,19 +2,19 @@
import { useState } from "react"; import { useState } from "react";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input"; import Badge from "@/components/ui/Badge";
interface Cliente { id: string; nombre: string; apellido: string; email: string; autos: Auto[]; } interface Cliente { id: string; nombre: string; apellido: string; email: string; autos: Auto[]; }
interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; } interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; }
interface Registro { interface Turno {
id: string; id: string;
descripcion: string; fechaInicio: string;
observaciones: string | null; fechaFin: string;
fechaRealizado: string; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
admin: { nombre: string }; kilometros: number;
tipoServicio: { nombre: string } | null; comentario: string | null;
tipoServicio: { nombre: string };
} }
interface TipoServicio { id: string; nombre: string; }
export default function AdminHistorialPage() { export default function AdminHistorialPage() {
const [busqueda, setBusqueda] = useState(""); const [busqueda, setBusqueda] = useState("");
@@ -22,11 +22,7 @@ export default function AdminHistorialPage() {
const [buscando, setBuscando] = useState(false); const [buscando, setBuscando] = useState(false);
const [clienteSel, setClienteSel] = useState<Cliente | null>(null); const [clienteSel, setClienteSel] = useState<Cliente | null>(null);
const [autoSel, setAutoSel] = useState<Auto | null>(null); const [autoSel, setAutoSel] = useState<Auto | null>(null);
const [historial, setHistorial] = useState<Registro[]>([]); const [turnos, setTurnos] = useState<Turno[]>([]);
const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState({ descripcion: "", observaciones: "", tipoServicioId: "", fechaRealizado: new Date().toISOString().slice(0, 10) });
const [guardando, setGuardando] = useState(false);
async function buscar(e: React.FormEvent) { async function buscar(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -41,50 +37,32 @@ export default function AdminHistorialPage() {
async function seleccionarAuto(auto: Auto) { async function seleccionarAuto(auto: Auto) {
setAutoSel(auto); setAutoSel(auto);
const [hRes, sRes] = await Promise.all([ const res = await fetch(`/api/admin/historial?autoId=${auto.id}`);
fetch(`/api/admin/historial?autoId=${auto.id}`), setTurnos(await res.json());
fetch(`/api/admin/servicios`),
]);
const [h, s] = await Promise.all([hRes.json(), sRes.json()]);
setHistorial(h);
setServicios(s.filter((sv: TipoServicio & { activo: boolean }) => sv.activo));
} }
function set(f: string) { function formatFecha(iso: string) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => return new Date(iso).toLocaleDateString("es-AR", {
setForm((v) => ({ ...v, [f]: e.target.value })); weekday: "short", day: "2-digit", month: "short", year: "numeric",
}
async function guardarRegistro(e: React.FormEvent) {
e.preventDefault();
if (!autoSel) return;
setGuardando(true);
const res = await fetch("/api/admin/historial", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ autoId: autoSel.id, ...form, tipoServicioId: form.tipoServicioId || null }),
}); });
if (res.ok) {
const nuevo = await res.json();
setHistorial((h) => [nuevo, ...h]);
setMostrarForm(false);
setForm({ descripcion: "", observaciones: "", tipoServicioId: "", fechaRealizado: new Date().toISOString().slice(0, 10) });
} }
setGuardando(false);
function formatHora(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
} }
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<h1 className="text-2xl font-bold text-gray-800">Historial de autos</h1> <h1 className="text-2xl font-bold text-white">Historial de autos</h1>
{/* Buscar cliente */} {/* Buscar cliente */}
<Card title="Buscar cliente"> <Card title="Buscar cliente">
<form onSubmit={buscar} className="flex gap-2"> <form onSubmit={buscar} className="flex gap-2">
<Input <input
placeholder="Nombre, apellido o email..." placeholder="Nombre, apellido o email..."
value={busqueda} value={busqueda}
onChange={(e) => setBusqueda(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBusqueda(e.target.value)}
className="flex-1" className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
/> />
<Button type="submit" loading={buscando}>Buscar</Button> <Button type="submit" loading={buscando}>Buscar</Button>
</form> </form>
@@ -94,7 +72,7 @@ export default function AdminHistorialPage() {
{clientes.map((c) => ( {clientes.map((c) => (
<button <button
key={c.id} key={c.id}
onClick={() => { setClienteSel(c); setAutoSel(null); setHistorial([]); }} onClick={() => { setClienteSel(c); setAutoSel(null); setTurnos([]); }}
className={`text-left px-3 py-2 rounded-lg border transition text-sm ${clienteSel?.id === c.id ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-blue-300"}`} className={`text-left px-3 py-2 rounded-lg border transition text-sm ${clienteSel?.id === c.id ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-blue-300"}`}
> >
<span className="font-medium">{c.apellido}, {c.nombre}</span> <span className="font-medium">{c.apellido}, {c.nombre}</span>
@@ -129,79 +107,32 @@ export default function AdminHistorialPage() {
{/* Historial del auto */} {/* Historial del auto */}
{autoSel && ( {autoSel && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <h2 className="text-lg font-semibold text-white">
<h2 className="text-lg font-semibold text-gray-700">
{autoSel.marca} {autoSel.modelo} · <span className="font-mono">{autoSel.patente}</span> {autoSel.marca} {autoSel.modelo} · <span className="font-mono">{autoSel.patente}</span>
</h2> </h2>
<Button onClick={() => setMostrarForm(true)} className="text-sm">+ Agregar registro</Button>
</div>
{mostrarForm && ( {turnos.length === 0 ? (
<Card title="Nuevo registro"> <Card><p className="text-center text-gray-400 py-4">Sin turnos registrados para este auto.</p></Card>
<form onSubmit={guardarRegistro} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Servicio (opcional)</label>
<select
value={form.tipoServicioId}
onChange={set("tipoServicioId")}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Sin categoría</option>
{servicios.map((s) => <option key={s.id} value={s.id}>{s.nombre}</option>)}
</select>
</div>
<Input id="fechaRealizado" type="date" label="Fecha realizado" value={form.fechaRealizado} onChange={set("fechaRealizado")} required />
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Descripción *</label>
<textarea
value={form.descripcion}
onChange={set("descripcion")}
required
rows={2}
placeholder="¿Qué se hizo?"
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Observaciones</label>
<textarea
value={form.observaciones}
onChange={set("observaciones")}
rows={2}
placeholder="Notas adicionales..."
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div className="flex gap-2 justify-end">
<Button variant="secondary" type="button" onClick={() => setMostrarForm(false)}>Cancelar</Button>
<Button type="submit" loading={guardando}>Guardar</Button>
</div>
</form>
</Card>
)}
{historial.length === 0 ? (
<Card><p className="text-center text-gray-400 py-4">Sin historial registrado.</p></Card>
) : ( ) : (
historial.map((reg) => ( turnos.map((t) => (
<Card key={reg.id}> <div key={t.id} className="bg-white border border-gray-200 rounded-2xl px-4 py-3 flex flex-col gap-2 shadow-sm">
<div className="flex items-start justify-between gap-2 flex-wrap mb-2"> <div className="flex items-start justify-between gap-2 flex-wrap">
<div> <div>
<p className="font-medium text-gray-800">{reg.descripcion}</p> <p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
{reg.tipoServicio && ( <p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">{reg.tipoServicio.nombre}</span> <p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</p>
</div>
<Badge estado={t.estado} />
</div>
<p className="text-xs text-gray-400">🔢 {t.kilometros.toLocaleString("es-AR")} km al ingreso</p>
{t.comentario && (
<div className="bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
<p className="text-xs text-gray-400 font-medium mb-0.5">Observaciones</p>
<p className="text-sm text-gray-700">{t.comentario}</p>
</div>
)} )}
</div> </div>
<p className="text-sm text-gray-400 shrink-0">
{new Date(reg.fechaRealizado).toLocaleDateString("es-AR", { day: "2-digit", month: "long", year: "numeric" })}
</p>
</div>
{reg.observaciones && (
<p className="text-sm text-gray-500 bg-gray-50 rounded px-3 py-2">{reg.observaciones}</p>
)}
<p className="text-xs text-gray-400 mt-2">Por: {reg.admin.nombre}</p>
</Card>
)) ))
)} )}
</div> </div>
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { useEffect, useState } from "react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
interface Jefe {
id: string;
nombre: string;
email: string;
slotsParalelos: number;
activo: boolean;
}
interface FormState {
nombre: string;
email: string;
password: string;
slotsParalelos: string;
}
const FORM_VACIO: FormState = { nombre: "", email: "", password: "", slotsParalelos: "1" };
export default function JefesPage() {
const [jefes, setJefes] = useState<Jefe[]>([]);
const [loading, setLoading] = useState(true);
const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState<FormState>(FORM_VACIO);
const [guardando, setGuardando] = useState(false);
const [editando, setEditando] = useState<Jefe | null>(null);
const [errorForm, setErrorForm] = useState("");
useEffect(() => { cargar(); }, []);
async function cargar() {
setLoading(true);
const res = await fetch("/api/admin/jefes");
setJefes(await res.json());
setLoading(false);
}
function set(f: keyof FormState) {
return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((v) => ({ ...v, [f]: e.target.value }));
}
async function guardar(e: React.FormEvent) {
e.preventDefault();
setGuardando(true);
setErrorForm("");
const slots = Number(form.slotsParalelos);
if (!Number.isInteger(slots) || slots < 1) {
setErrorForm("Slots en paralelo debe ser un entero >= 1");
setGuardando(false);
return;
}
if (editando) {
const body: Record<string, unknown> = {
nombre: form.nombre,
email: form.email,
slotsParalelos: slots,
};
if (form.password) body.password = form.password;
const res = await fetch(`/api/admin/jefes/${editando.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorForm(data?.error || "Error al guardar");
setGuardando(false);
return;
}
} else {
if (!form.password) {
setErrorForm("Contraseña requerida");
setGuardando(false);
return;
}
const res = await fetch("/api/admin/jefes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nombre: form.nombre,
email: form.email,
password: form.password,
slotsParalelos: slots,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorForm(data?.error || "Error al guardar");
setGuardando(false);
return;
}
}
setGuardando(false);
setMostrarForm(false);
setEditando(null);
setForm(FORM_VACIO);
cargar();
}
async function toggleActivo(j: Jefe) {
await fetch(`/api/admin/jefes/${j.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activo: !j.activo }),
});
cargar();
}
function iniciarEdicion(j: Jefe) {
setEditando(j);
setForm({
nombre: j.nombre,
email: j.email,
password: "",
slotsParalelos: String(j.slotsParalelos),
});
setMostrarForm(true);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Jefes mecánicos</h1>
<Button
onClick={() => { setMostrarForm(true); setEditando(null); setForm(FORM_VACIO); }}
className="text-sm"
>
+ Nuevo jefe
</Button>
</div>
{mostrarForm && (
<Card title={editando ? "Editar jefe" : "Nuevo jefe"}>
<form onSubmit={guardar} className="flex flex-col gap-4">
<Input id="nombre" label="Nombre" placeholder="Juan Pérez" value={form.nombre} onChange={set("nombre")} required />
<Input id="email" type="email" label="Email" placeholder="juan@taller.com" value={form.email} onChange={set("email")} required />
<Input
id="password"
type="password"
label={editando ? "Nueva contraseña (opcional)" : "Contraseña"}
placeholder={editando ? "Dejar vacío para no cambiar" : "Contraseña inicial"}
value={form.password}
onChange={set("password")}
{...(editando ? {} : { required: true })}
/>
<Input
id="slots"
type="number"
label="Turnos en paralelo"
placeholder="1"
step="1"
min="1"
max="20"
value={form.slotsParalelos}
onChange={set("slotsParalelos")}
required
/>
{errorForm && <p className="text-sm text-red-500">{errorForm}</p>}
<div className="flex gap-2 justify-end">
<Button variant="secondary" type="button" onClick={() => { setMostrarForm(false); setEditando(null); }}>Cancelar</Button>
<Button type="submit" loading={guardando}>Guardar</Button>
</div>
</form>
</Card>
)}
{loading ? (
<div className="text-center py-10 text-gray-200">Cargando...</div>
) : (
<div className="flex flex-col gap-3">
{jefes.map((j) => (
<div
key={j.id}
className={`flex items-center justify-between gap-3 bg-white border rounded-xl px-4 py-3 ${!j.activo ? "opacity-50" : ""}`}
>
<div>
<p className={`font-medium ${j.activo ? "text-gray-800" : "text-gray-400 line-through"}`}>
{j.nombre}
</p>
<p className="text-sm text-gray-500">
{j.email} · {j.slotsParalelos} {j.slotsParalelos === 1 ? "turno paralelo" : "turnos paralelos"}
</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => iniciarEdicion(j)} className="text-sm px-2 py-1">
✏️
</Button>
<Button
variant={j.activo ? "secondary" : "ghost"}
onClick={() => toggleActivo(j)}
className="text-sm px-3 py-1"
>
{j.activo ? "Desactivar" : "Activar"}
</Button>
</div>
</div>
))}
{jefes.length === 0 && (
<p className="text-center text-gray-200 py-4 text-sm">No hay jefes mecánicos cargados.</p>
)}
</div>
)}
</div>
);
}
+12 -12
View File
@@ -7,6 +7,7 @@ const navItems = [
{ href: "/admin/historial", label: "Historial", icon: "📋" }, { href: "/admin/historial", label: "Historial", icon: "📋" },
{ href: "/admin/clientes", label: "Clientes", icon: "👥" }, { href: "/admin/clientes", label: "Clientes", icon: "👥" },
{ href: "/admin/servicios", label: "Servicios", icon: "⚙️" }, { href: "/admin/servicios", label: "Servicios", icon: "⚙️" },
{ href: "/admin/jefes", label: "Jefes", icon: "🔧" },
]; ];
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
@@ -21,18 +22,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (pathname === "/admin") return <>{children}</>; if (pathname === "/admin") return <>{children}</>;
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col"> <div className="min-h-screen flex flex-col">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<span className="font-bold text-blue-600 text-lg">🔧 Taller — Admin</span>
<button onClick={cerrarSesion} className="text-sm text-gray-500 hover:text-red-600 transition">
Cerrar sesión
</button>
</div>
</header>
<div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col md:flex-row gap-6 py-6"> <div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col md:flex-row gap-6 py-6">
<nav className="md:w-48 shrink-0"> <nav className="md:w-48 shrink-0 bg-black/50 backdrop-blur-sm md:bg-transparent rounded-xl md:rounded-none p-2 md:p-0">
<ul className="flex md:flex-col gap-2 flex-wrap"> <ul className="flex md:flex-col gap-2 flex-wrap">
{navItems.map((item) => ( {navItems.map((item) => (
<li key={item.href}> <li key={item.href}>
@@ -41,7 +33,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition ${ className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition ${
pathname.startsWith(item.href) pathname.startsWith(item.href)
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100" : "text-gray-100 hover:bg-white/20"
}`} }`}
> >
<span>{item.icon}</span> <span>{item.icon}</span>
@@ -49,6 +41,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</Link> </Link>
</li> </li>
))} ))}
<li className="md:mt-auto">
<button
onClick={cerrarSesion}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-300 hover:text-red-300 hover:bg-white/10 transition w-full"
>
Cerrar sesión
</button>
</li>
</ul> </ul>
</nav> </nav>
+1 -1
View File
@@ -31,7 +31,7 @@ export default function AdminLoginPage() {
} }
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50"> <div className="flex flex-col items-center justify-center min-h-screen">
<Card className="w-full max-w-sm mx-4"> <Card className="w-full max-w-sm mx-4">
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="text-4xl mb-2">🔧</div> <div className="text-4xl mb-2">🔧</div>
+2 -2
View File
@@ -74,7 +74,7 @@ export default function ServiciosPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-800">Tipos de servicio</h1> <h1 className="text-2xl font-bold text-white">Tipos de servicio</h1>
<Button <Button
onClick={() => { setMostrarForm(true); setEditando(null); setForm({ nombre: "", duracionHoras: "" }); }} onClick={() => { setMostrarForm(true); setEditando(null); setForm({ nombre: "", duracionHoras: "" }); }}
className="text-sm" className="text-sm"
@@ -98,7 +98,7 @@ export default function ServiciosPage() {
)} )}
{loading ? ( {loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div> <div className="text-center py-10 text-gray-200">Cargando...</div>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{servicios.map((s) => ( {servicios.map((s) => (
+1 -1
View File
@@ -15,7 +15,7 @@ export async function GET(req: NextRequest) {
OR: [ OR: [
{ nombre: { contains: q, mode: "insensitive" } }, { nombre: { contains: q, mode: "insensitive" } },
{ apellido: { contains: q, mode: "insensitive" } }, { apellido: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } }, { autos: { some: { patente: { contains: q, mode: "insensitive" } } } },
], ],
} }
: undefined, : undefined,
+4 -4
View File
@@ -10,13 +10,13 @@ export async function GET(req: NextRequest) {
const autoId = searchParams.get("autoId"); const autoId = searchParams.get("autoId");
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 }); if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
const historial = await prisma.historialServicio.findMany({ const turnos = await prisma.turno.findMany({
where: { autoId }, where: { autoId },
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } }, include: { tipoServicio: { select: { nombre: true } } },
orderBy: { fechaRealizado: "desc" }, orderBy: { fechaInicio: "desc" },
}); });
return NextResponse.json(historial); return NextResponse.json(turnos);
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import bcrypt from "bcryptjs";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
const data = await req.json();
const update: {
nombre?: string;
email?: string;
slotsParalelos?: number;
activo?: boolean;
passwordHash?: string;
} = {};
if (data.nombre !== undefined) update.nombre = data.nombre;
if (data.email !== undefined) update.email = String(data.email).toLowerCase().trim();
if (data.slotsParalelos !== undefined) {
const slots = Number(data.slotsParalelos);
if (!Number.isInteger(slots) || slots < 1)
return NextResponse.json({ error: "slotsParalelos debe ser un entero >= 1" }, { status: 400 });
update.slotsParalelos = slots;
}
if (data.activo !== undefined) update.activo = !!data.activo;
if (data.password) {
update.passwordHash = await bcrypt.hash(data.password, 10);
}
const jefe = await prisma.jefeMecanico.update({
where: { id },
data: update,
select: { id: true, nombre: true, email: true, slotsParalelos: true, activo: true, createdAt: true },
});
return NextResponse.json(jefe);
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import bcrypt from "bcryptjs";
export async function GET() {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefes = await prisma.jefeMecanico.findMany({
orderBy: { nombre: "asc" },
select: { id: true, nombre: true, email: true, slotsParalelos: true, activo: true, createdAt: true },
});
return NextResponse.json(jefes);
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, email, password, slotsParalelos } = await req.json();
if (!nombre || !email || !password)
return NextResponse.json({ error: "nombre, email y password requeridos" }, { status: 400 });
const slots = Number(slotsParalelos);
if (!Number.isInteger(slots) || slots < 1)
return NextResponse.json({ error: "slotsParalelos debe ser un entero >= 1" }, { status: 400 });
const emailNorm = String(email).toLowerCase().trim();
const existente = await prisma.jefeMecanico.findUnique({ where: { email: emailNorm } });
if (existente) return NextResponse.json({ error: "Ya existe un jefe con ese email" }, { status: 409 });
const passwordHash = await bcrypt.hash(password, 10);
const jefe = await prisma.jefeMecanico.create({
data: { nombre, email: emailNorm, passwordHash, slotsParalelos: slots },
select: { id: true, nombre: true, email: true, slotsParalelos: true, activo: true, createdAt: true },
});
return NextResponse.json(jefe, { status: 201 });
}
+24 -2
View File
@@ -1,19 +1,41 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
import { agregarFilaSheet } from "@/lib/google-sheets";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("admin_session"); const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params; const { id } = await params;
const { estado } = await req.json(); const { estado, comentario } = await req.json();
const turno = await prisma.turno.update({ const turno = await prisma.turno.update({
where: { id }, where: { id },
data: { estado }, data: { estado, ...(comentario !== undefined && { comentario }) },
include: { auto: true, tipoServicio: true, cliente: true }, include: { auto: true, tipoServicio: true, cliente: true },
}); });
if (estado === "COMPLETADO") {
try {
await agregarFilaSheet({
patente: turno.auto.patente,
kilometros: turno.kilometros,
marca: turno.auto.marca,
modelo: turno.auto.modelo,
anio: turno.auto.anio,
nombre: turno.cliente.nombre,
apellido: turno.cliente.apellido,
email: turno.cliente.email,
telefono: turno.cliente.telefono,
fecha: turno.fechaInicio,
trabajo: turno.tipoServicio.nombre,
comentario: turno.comentario,
});
} catch (err) {
console.error("Error escribiendo en Google Sheet:", err);
}
}
return NextResponse.json(turno); return NextResponse.json(turno);
} }
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { crearSesionJefe } from "@/lib/auth-admin";
import bcrypt from "bcryptjs";
export async function POST(req: NextRequest) {
const { email, password } = await req.json();
if (!email || !password)
return NextResponse.json({ error: "Credenciales requeridas" }, { status: 400 });
const jefe = await prisma.jefeMecanico.findUnique({
where: { email: String(email).toLowerCase().trim() },
});
if (!jefe || !jefe.activo)
return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 });
const ok = await bcrypt.compare(password, jefe.passwordHash);
if (!ok) return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 });
const jwt = await crearSesionJefe(jefe.id);
const response = NextResponse.json({ ok: true, jefe: { id: jefe.id, nombre: jefe.nombre } });
response.cookies.set("jefe_session", jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 8,
path: "/",
});
return response;
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set("jefe_session", "", { maxAge: 0, path: "/" });
return response;
}
+2 -1
View File
@@ -18,7 +18,7 @@ export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { patente, marca, modelo, anio } = await req.json(); const { patente, marca, modelo, anio, nroChasis } = await req.json();
if (!patente || !marca || !modelo || !anio) if (!patente || !marca || !modelo || !anio)
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 }); return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
@@ -32,6 +32,7 @@ export async function POST(req: NextRequest) {
marca, marca,
modelo, modelo,
anio: Number(anio), anio: Number(anio),
nroChasis: nroChasis?.trim() || null,
}, },
}); });
+4 -4
View File
@@ -14,11 +14,11 @@ export async function GET(req: NextRequest) {
const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } }); const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } });
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 }); if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
const historial = await prisma.historialServicio.findMany({ const turnos = await prisma.turno.findMany({
where: { autoId }, where: { autoId },
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } }, include: { tipoServicio: { select: { nombre: true } } },
orderBy: { fechaRealizado: "desc" }, orderBy: { fechaInicio: "desc" },
}); });
return NextResponse.json(historial); return NextResponse.json(turnos);
} }
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET() {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefes = await prisma.jefeMecanico.findMany({
where: { activo: true },
orderBy: { nombre: "asc" },
select: { id: true, nombre: true },
});
return NextResponse.json(jefes);
}
+13 -5
View File
@@ -1,8 +1,16 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Resend } from "resend"; import nodemailer from "nodemailer";
const resend = new Resend(process.env.RESEND_API_KEY); const transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: {
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_APP_PASSWORD,
},
});
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const { email, nombre, apellido, telefono } = await req.json(); const { email, nombre, apellido, telefono } = await req.json();
@@ -17,9 +25,9 @@ export async function POST(req: NextRequest) {
data: { email: email.toLowerCase().trim(), nombre, apellido, telefono }, data: { email: email.toLowerCase().trim(), nombre, apellido, telefono },
}); });
await resend.emails.send({ await transporter.sendMail({
from: "Taller <onboarding@resend.dev>", from: `"Taller" <${process.env.GMAIL_USER}>`,
to: "jpbeldomenico@gmail.com", to: email,
subject: "¡Bienvenido al taller!", subject: "¡Bienvenido al taller!",
html: ` html: `
<h2>¡Hola ${nombre}!</h2> <h2>¡Hola ${nombre}!</h2>
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET() {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const servicios = await prisma.tipoServicio.findMany({
where: { activo: true },
orderBy: { nombre: "asc" },
});
return NextResponse.json(servicios);
}
+4 -1
View File
@@ -9,13 +9,16 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const tipoServicioId = searchParams.get("tipoServicioId"); const tipoServicioId = searchParams.get("tipoServicioId");
const jefeMecanicoId = searchParams.get("jefeMecanicoId");
if (!tipoServicioId) if (!tipoServicioId)
return NextResponse.json({ error: "tipoServicioId requerido" }, { status: 400 }); return NextResponse.json({ error: "tipoServicioId requerido" }, { status: 400 });
if (!jefeMecanicoId)
return NextResponse.json({ error: "jefeMecanicoId requerido" }, { status: 400 });
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } }); const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo) if (!servicio || !servicio.activo)
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 }); return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 });
const slots = await obtenerSlotsDisponibles(servicio.duracionHoras); const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500);
return NextResponse.json(slots); return NextResponse.json(slots);
} }
+22 -6
View File
@@ -9,7 +9,7 @@ export async function GET() {
const turnos = await prisma.turno.findMany({ const turnos = await prisma.turno.findMany({
where: { clienteId: sesion.sub }, where: { clienteId: sesion.sub },
include: { auto: true, tipoServicio: true }, include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } },
orderBy: { fechaInicio: "desc" }, orderBy: { fechaInicio: "desc" },
}); });
@@ -20,10 +20,14 @@ export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, fechaInicio } = await req.json(); const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json();
if (!autoId || !tipoServicioId || !fechaInicio) if (!autoId || !tipoServicioId || !jefeMecanicoId || !fechaInicio || !kilometros)
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 }); return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
const km = Number(kilometros);
if (!Number.isInteger(km) || km <= 0 || km > 1000000)
return NextResponse.json({ error: "Kilometraje inválido (debe ser entre 1 y 1.000.000)" }, { status: 400 });
// Verificar que el auto pertenece al cliente // Verificar que el auto pertenece al cliente
const auto = await prisma.auto.findFirst({ const auto = await prisma.auto.findFirst({
where: { id: autoId, clienteId: sesion.sub }, where: { id: autoId, clienteId: sesion.sub },
@@ -34,16 +38,28 @@ export async function POST(req: NextRequest) {
if (!servicio || !servicio.activo) if (!servicio || !servicio.activo)
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 }); return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 });
const jefe = await prisma.jefeMecanico.findUnique({ where: { id: jefeMecanicoId } });
if (!jefe || !jefe.activo)
return NextResponse.json({ error: "Jefe mecánico no disponible" }, { status: 404 });
const inicio = new Date(fechaInicio); const inicio = new Date(fechaInicio);
const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000); const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
const disponible = await slotEstaDisponible(inicio, fin); const disponible = await slotEstaDisponible(inicio, fin, jefeMecanicoId);
if (!disponible) if (!disponible)
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 }); return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
const turno = await prisma.turno.create({ const turno = await prisma.turno.create({
data: { clienteId: sesion.sub, autoId, tipoServicioId, fechaInicio: inicio, fechaFin: fin }, data: {
include: { auto: true, tipoServicio: true }, clienteId: sesion.sub,
autoId,
tipoServicioId,
jefeMecanicoId,
fechaInicio: inicio,
fechaFin: fin,
kilometros: km,
},
include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } },
}); });
return NextResponse.json(turno, { status: 201 }); return NextResponse.json(turno, { status: 201 });
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("jefe_session");
if (!sesion || sesion.role !== "jefe")
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
const fecha = searchParams.get("fecha"); // YYYY-MM-DD
const where: Record<string, unknown> = { jefeMecanicoId: sesion.sub };
if (fecha) {
where.fechaInicio = {
gte: new Date(`${fecha}T00:00:00`),
lte: new Date(`${fecha}T23:59:59`),
};
}
const turnos = await prisma.turno.findMany({
where,
include: {
cliente: { select: { nombre: true, apellido: true, telefono: true } },
auto: true,
tipoServicio: true,
},
orderBy: { fechaInicio: "asc" },
});
return NextResponse.json(turnos);
}
+4 -4
View File
@@ -31,7 +31,7 @@ function AuthContent() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg> </svg>
<p className="text-gray-500">Verificando tu acceso...</p> <p className="text-gray-200">Verificando tu acceso...</p>
</div> </div>
); );
} }
@@ -39,9 +39,9 @@ function AuthContent() {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4"> <div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
<div className="text-5xl">❌</div> <div className="text-5xl">❌</div>
<h1 className="text-xl font-bold text-gray-800">Link inválido o expirado</h1> <h1 className="text-xl font-bold text-white">Link inválido o expirado</h1>
<p className="text-gray-500">El link de acceso ya fue usado o expiró. Pedí uno nuevo.</p> <p className="text-gray-200">El link de acceso ya fue usado o expiró. Pedí uno nuevo.</p>
<a href="/cliente" className="text-blue-600 hover:underline font-medium"> <a href="/cliente" className="text-blue-300 hover:underline font-medium">
Volver al inicio Volver al inicio
</a> </a>
</div> </div>
+37 -13
View File
@@ -5,6 +5,8 @@ import Link from "next/link";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";
import Combobox from "@/components/ui/Combobox";
import { MARCAS, getModelos } from "@/lib/marcas-modelos";
interface Auto { interface Auto {
id: string; id: string;
@@ -12,6 +14,7 @@ interface Auto {
marca: string; marca: string;
modelo: string; modelo: string;
anio: number; anio: number;
nroChasis?: string | null;
} }
export default function DashboardPage() { export default function DashboardPage() {
@@ -19,7 +22,8 @@ export default function DashboardPage() {
const [autos, setAutos] = useState<Auto[]>([]); const [autos, setAutos] = useState<Auto[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [mostrarForm, setMostrarForm] = useState(false); const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState({ patente: "", marca: "", modelo: "", anio: "" }); const [form, setForm] = useState({ patente: "", marca: "", modelo: "", anio: "", nroChasis: "" });
const modelosDisponibles = getModelos(form.marca);
const [guardando, setGuardando] = useState(false); const [guardando, setGuardando] = useState(false);
const [errorForm, setErrorForm] = useState(""); const [errorForm, setErrorForm] = useState("");
@@ -51,7 +55,7 @@ export default function DashboardPage() {
setGuardando(false); setGuardando(false);
if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; } if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; }
setMostrarForm(false); setMostrarForm(false);
setForm({ patente: "", marca: "", modelo: "", anio: "" }); setForm({ patente: "", marca: "", modelo: "", anio: "", nroChasis: "" });
cargarAutos(); cargarAutos();
} }
@@ -63,19 +67,19 @@ export default function DashboardPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-800">Mis autos</h1> <h1 className="text-2xl font-bold text-white">Mis autos</h1>
<button onClick={cerrarSesion} className="text-sm text-gray-400 hover:text-red-500 transition"> <button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
Salir Salir
</button> </button>
</div> </div>
{loading ? ( {loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div> <div className="text-center py-10 text-gray-200">Cargando...</div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{autos.map((auto) => ( {autos.map((auto) => (
<Card key={auto.id} className="hover:shadow-md transition"> <Card key={auto.id} className="hover:shadow-md transition">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<p className="font-semibold text-gray-800"> <p className="font-semibold text-gray-800">
{auto.marca} {auto.modelo} <span className="text-gray-500">({auto.anio})</span> {auto.marca} {auto.modelo} <span className="text-gray-500">({auto.anio})</span>
@@ -83,13 +87,13 @@ export default function DashboardPage() {
<p className="text-sm text-gray-500 font-mono mt-0.5">{auto.patente}</p> <p className="text-sm text-gray-500 font-mono mt-0.5">{auto.patente}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/cliente/historial?autoId=${auto.id}`}> <Link href={`/cliente/historial?autoId=${auto.id}`} className="flex-1 sm:flex-none">
<Button variant="secondary" className="text-sm"> <Button variant="secondary" className="text-sm w-full sm:w-auto">
📋 Historial 📋 Historial
</Button> </Button>
</Link> </Link>
<Link href={`/cliente/turnos?autoId=${auto.id}`}> <Link href={`/cliente/turnos?autoId=${auto.id}`} className="flex-1 sm:flex-none">
<Button className="text-sm"> <Button className="text-sm w-full sm:w-auto">
📅 Sacar turno 📅 Sacar turno
</Button> </Button>
</Link> </Link>
@@ -99,7 +103,7 @@ export default function DashboardPage() {
))} ))}
{autos.length === 0 && !mostrarForm && ( {autos.length === 0 && !mostrarForm && (
<p className="text-center text-gray-400 py-4">No tenés autos registrados todavía.</p> <p className="text-center text-gray-200 py-4">No tenés autos registrados todavía.</p>
)} )}
</div> </div>
)} )}
@@ -108,10 +112,30 @@ export default function DashboardPage() {
<Card title="Agregar auto"> <Card title="Agregar auto">
<form onSubmit={agregarAuto} className="flex flex-col gap-4"> <form onSubmit={agregarAuto} className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Input id="marca" label="Marca" placeholder="Ford" value={form.marca} onChange={set("marca")} required /> <Combobox
<Input id="modelo" label="Modelo" placeholder="Focus" value={form.modelo} onChange={set("modelo")} required /> id="marca"
label="Marca"
options={MARCAS}
value={form.marca}
onChange={(v) => setForm((f) => ({ ...f, marca: v, modelo: "" }))}
onSelect={(v) => setForm((f) => ({ ...f, marca: v, modelo: "" }))}
placeholder="Ford"
required
/>
<Combobox
id="modelo"
label="Modelo"
options={modelosDisponibles}
value={form.modelo}
onChange={(v) => setForm((f) => ({ ...f, modelo: v }))}
onSelect={(v) => setForm((f) => ({ ...f, modelo: v }))}
placeholder={form.marca ? "Focus" : "Primero elegí la marca"}
disabled={modelosDisponibles.length === 0}
required
/>
<Input id="anio" label="Año" type="number" placeholder="2018" value={form.anio} onChange={set("anio")} required min={1960} max={new Date().getFullYear() + 1} /> <Input id="anio" label="Año" type="number" placeholder="2018" value={form.anio} onChange={set("anio")} required min={1960} max={new Date().getFullYear() + 1} />
<Input id="patente" label="Patente" placeholder="AB123CD" value={form.patente} onChange={set("patente")} required className="uppercase" /> <Input id="patente" label="Patente" placeholder="AB123CD" value={form.patente} onChange={set("patente")} required className="uppercase" />
<Input id="nroChasis" label="Nº Chasis (opcional)" placeholder="8AFZZZ11ZYM569874" value={form.nroChasis} onChange={set("nroChasis")} className="col-span-2" />
</div> </div>
{errorForm && <p className="text-sm text-red-500">{errorForm}</p>} {errorForm && <p className="text-sm text-red-500">{errorForm}</p>}
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
+49 -40
View File
@@ -2,23 +2,24 @@
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
interface Registro { interface Turno {
id: string; id: string;
descripcion: string; fechaInicio: string;
observaciones: string | null; fechaFin: string;
fechaRealizado: string; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
admin: { nombre: string }; kilometros: number;
tipoServicio: { nombre: string } | null; comentario: string | null;
tipoServicio: { nombre: string };
} }
function HistorialContent() { function HistorialContent() {
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const autoId = params.get("autoId"); const autoId = params.get("autoId");
const [historial, setHistorial] = useState<Registro[]>([]); const [turnos, setTurnos] = useState<Turno[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -29,53 +30,61 @@ function HistorialContent() {
return r; return r;
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { setHistorial(data); setLoading(false); }) .then((data) => { setTurnos(data); setLoading(false); })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, [autoId, router]); }, [autoId, router]);
function formatFecha(iso: string) {
return new Date(iso).toLocaleDateString("es-AR", {
weekday: "long", day: "2-digit", month: "long", year: "numeric",
});
}
function formatHora(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/cliente/dashboard"> <Link href="/cliente/dashboard">
<Button variant="ghost" className="px-2">← Volver</Button> <Button variant="ghost" className="px-2">← Volver</Button>
</Link> </Link>
<h1 className="text-2xl font-bold text-gray-800">Historial del auto</h1> <h1 className="text-xl sm:text-2xl font-bold text-white">Historial del auto</h1>
</div> </div>
{loading ? ( {loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div> <div className="text-center py-10 text-gray-200">Cargando...</div>
) : historial.length === 0 ? ( ) : turnos.length === 0 ? (
<Card> <div className="bg-white border border-gray-200 rounded-2xl p-8 text-center">
<p className="text-center text-gray-400 py-4">Este auto no tiene historial registrado todavía.</p> <p className="text-4xl mb-3">🔧</p>
</Card> <p className="text-gray-500 font-medium">Sin turnos registrados</p>
<p className="text-sm text-gray-400 mt-1">Los turnos aparecerán aquí cuando reservés uno.</p>
</div>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{historial.map((reg) => ( {turnos.map((t) => (
<Card key={reg.id}> <div key={t.id} className="bg-white border border-gray-200 rounded-2xl px-4 py-3 flex flex-col gap-2 shadow-sm">
<div className="flex flex-col gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-start justify-between gap-2 flex-wrap">
<div> <div>
<p className="font-semibold text-gray-800">{reg.descripcion}</p> <p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
{reg.tipoServicio && ( <p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full"> <p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</p>
{reg.tipoServicio.nombre} </div>
</span> <Badge estado={t.estado} />
</div>
<div className="flex gap-4 text-xs text-gray-500">
<span>🔢 {t.kilometros.toLocaleString("es-AR")} km</span>
</div>
{t.comentario && (
<div className="bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
<p className="text-xs text-gray-400 font-medium mb-0.5">Observaciones del taller</p>
<p className="text-sm text-gray-700">{t.comentario}</p>
</div>
)} )}
</div> </div>
<p className="text-sm text-gray-400 shrink-0">
{new Date(reg.fechaRealizado).toLocaleDateString("es-AR", {
day: "2-digit", month: "long", year: "numeric",
})}
</p>
</div>
{reg.observaciones && (
<p className="text-sm text-gray-500 bg-gray-50 rounded-lg px-3 py-2">
{reg.observaciones}
</p>
)}
<p className="text-xs text-gray-400">Realizado por: {reg.admin.nombre}</p>
</div>
</Card>
))} ))}
</div> </div>
)} )}
@@ -85,7 +94,7 @@ function HistorialContent() {
export default function HistorialPage() { export default function HistorialPage() {
return ( return (
<Suspense fallback={<div className="text-center py-10 text-gray-400">Cargando...</div>}> <Suspense fallback={<div className="text-center py-10 text-gray-200">Cargando...</div>}>
<HistorialContent /> <HistorialContent />
</Suspense> </Suspense>
); );
+2 -7
View File
@@ -6,13 +6,8 @@ export const metadata: Metadata = {
export default function ClienteLayout({ children }: { children: React.ReactNode }) { export default function ClienteLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10"> <main className="max-w-2xl mx-auto px-4 py-8">{children}</main>
<div className="max-w-2xl mx-auto px-4 h-14 flex items-center justify-between">
<span className="font-bold text-blue-600 text-lg">🔧 Taller</span>
</div>
</header>
<main className="max-w-2xl mx-auto px-4 py-6">{children}</main>
</div> </div>
); );
} }
+3 -3
View File
@@ -33,12 +33,12 @@ export default function ClienteLoginPage() {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4"> <div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
<div className="text-5xl">📧</div> <div className="text-5xl">📧</div>
<h1 className="text-2xl font-bold text-gray-800">Revisá tu email</h1> <h1 className="text-2xl font-bold text-white">Revisá tu email</h1>
<p className="text-gray-500 max-w-sm"> <p className="text-gray-200 max-w-sm">
Te enviamos un link de acceso a <strong>{email}</strong>. El link expira en 15 minutos. Te enviamos un link de acceso a <strong>{email}</strong>. El link expira en 15 minutos.
</p> </p>
<button <button
className="text-sm text-blue-600 hover:underline mt-2" className="text-sm text-blue-300 hover:underline mt-2"
onClick={() => { setEnviado(false); setEmail(""); }} onClick={() => { setEnviado(false); setEmail(""); }}
> >
Usar otro email Usar otro email
+330 -83
View File
@@ -1,5 +1,5 @@
"use client"; "use client";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
@@ -7,17 +7,59 @@ import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge"; import Badge from "@/components/ui/Badge";
interface TipoServicio { id: string; nombre: string; duracionHoras: number; } interface TipoServicio { id: string; nombre: string; duracionHoras: number; }
interface Jefe { id: string; nombre: string; }
interface Slot { fechaInicio: string; fechaFin: string; } interface Slot { fechaInicio: string; fechaFin: string; }
interface Turno { interface Turno {
id: string; id: string; fechaInicio: string; fechaFin: string;
fechaInicio: string;
fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO"; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
tipoServicio: { nombre: string }; tipoServicio: { nombre: string };
auto: { marca: string; modelo: string; patente: string }; auto: { marca: string; modelo: string; patente: string };
jefeMecanico?: { nombre: string } | null;
} }
type Step = "servicio" | "slot" | "confirmado"; const MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"];
type Step = "servicio" | "jefe" | "calendario" | "confirmado";
function formatDuracion(h: number) {
if (h < 1) return `${Math.round(h * 60)} min`;
if (h === 1) return "1 hora";
if (Number.isInteger(h)) return `${h} horas`;
return `${Math.floor(h)} h ${Math.round((h % 1) * 60)} min`;
}
// Convierte ISO UTC a fecha Argentina "YYYY-MM-DD"
function fechaArg(iso: string) {
return new Date(iso).toLocaleDateString("sv-SE", { timeZone: "America/Argentina/Buenos_Aires" });
}
// Convierte ISO UTC a hora Argentina "HH:MM"
function horaArg(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires", hour: "2-digit", minute: "2-digit",
});
}
// Hoy en Argentina "YYYY-MM-DD"
function hoyArg() {
return new Date().toLocaleDateString("sv-SE", { timeZone: "America/Argentina/Buenos_Aires" });
}
// Celdas del mes (null = celda vacía antes del día 1)
function celdasMes(year: number, month: number): (number | null)[] {
const primer = new Date(year, month, 1);
const total = new Date(year, month + 1, 0).getDate();
const offset = (primer.getDay() + 6) % 7; // Lunes = 0
const result: (number | null)[] = Array(offset).fill(null);
for (let d = 1; d <= total; d++) result.push(d);
return result;
}
function esFinDeSemana(year: number, month: number, day: number) {
const dow = new Date(year, month, day).getDay();
return dow === 0 || dow === 6;
}
function TurnosContent() { function TurnosContent() {
const router = useRouter(); const router = useRouter();
@@ -27,75 +69,165 @@ function TurnosContent() {
const [step, setStep] = useState<Step>("servicio"); const [step, setStep] = useState<Step>("servicio");
const [servicios, setServicios] = useState<TipoServicio[]>([]); const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null); const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
const [jefes, setJefes] = useState<Jefe[]>([]);
const [jefeSel, setJefeSel] = useState<Jefe | null>(null);
const [loadingJefes, setLoadingJefes] = useState(false);
const [slots, setSlots] = useState<Slot[]>([]); const [slots, setSlots] = useState<Slot[]>([]);
const [slotSel, setSlotSel] = useState<Slot | null>(null);
const [loadingSlots, setLoadingSlots] = useState(false); const [loadingSlots, setLoadingSlots] = useState(false);
const [reservando, setReservando] = useState(false);
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null);
const [misTurnos, setMisTurnos] = useState<Turno[]>([]); const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
const [loadingTurnos, setLoadingTurnos] = useState(true); const [loadingTurnos, setLoadingTurnos] = useState(true);
// Calendario
const hoy = new Date();
const [mesVista, setMesVista] = useState({ year: hoy.getFullYear(), month: hoy.getMonth() });
const [diaSeleccionado, setDiaSeleccionado] = useState<string | null>(null);
const [slotSel, setSlotSel] = useState<Slot | null>(null);
const [kilometros, setKilometros] = useState("");
const [kmError, setKmError] = useState("");
const [reservando, setReservando] = useState(false);
const [reservaError, setReservaError] = useState("");
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null);
useEffect(() => { useEffect(() => {
if (!autoId) { router.push("/cliente/dashboard"); return; } if (!autoId) { router.push("/cliente/dashboard"); return; }
fetch("/api/admin/servicios") fetch("/api/cliente/servicios").then(r => r.json()).then(setServicios);
.then((r) => r.json())
.then((data) => setServicios(data.filter((s: TipoServicio & { activo: boolean }) => s.activo)));
fetch("/api/cliente/turnos") fetch("/api/cliente/turnos")
.then((r) => r.json()) .then(r => r.json())
.then((data) => { setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId)); setLoadingTurnos(false); }); .then((data) => {
setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId));
setLoadingTurnos(false);
});
}, [autoId, router]); }, [autoId, router]);
// Agrupar slots por fecha Argentina
const slotsPorDia = useMemo(() => {
const m: Record<string, Slot[]> = {};
for (const s of slots) {
const d = fechaArg(s.fechaInicio);
if (!m[d]) m[d] = [];
m[d].push(s);
}
return m;
}, [slots]);
async function seleccionarServicio(s: TipoServicio) { async function seleccionarServicio(s: TipoServicio) {
setServicioSel(s); setServicioSel(s);
setLoadingSlots(true); setJefeSel(null);
setStep("slot"); setSlots([]);
const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}`); setDiaSeleccionado(null);
setSlotSel(null);
setLoadingJefes(true);
setStep("jefe");
const res = await fetch("/api/cliente/jefes");
const data = await res.json(); const data = await res.json();
setSlots(data); setJefes(Array.isArray(data) ? data : []);
setLoadingJefes(false);
}
async function seleccionarJefe(j: Jefe) {
if (!servicioSel) return;
setJefeSel(j);
setLoadingSlots(true);
setSlots([]);
setDiaSeleccionado(null);
setSlotSel(null);
setStep("calendario");
const res = await fetch(`/api/cliente/slots?tipoServicioId=${servicioSel.id}&jefeMecanicoId=${j.id}`);
const data = await res.json();
setSlots(Array.isArray(data) ? data : []);
setLoadingSlots(false); setLoadingSlots(false);
} }
function seleccionarDia(dateStr: string) {
setDiaSeleccionado(dateStr);
setSlotSel(null);
setKilometros("");
setKmError("");
}
function handleKilometros(val: string) {
setKilometros(val);
if (val === "") {
setKmError("");
} else if (!/^\d+$/.test(val) || Number(val) <= 0) {
setKmError("Ingresá solo números enteros positivos");
} else if (Number(val) > 1000000) {
setKmError("El kilometraje no puede superar 1.000.000 km");
} else {
setKmError("");
}
}
async function reservar() { async function reservar() {
if (!slotSel || !servicioSel || !autoId) return; if (!slotSel || !servicioSel || !jefeSel || !autoId || !kilometros || kmError) return;
setReservando(true); setReservando(true);
setReservaError("");
const res = await fetch("/api/cliente/turnos", { const res = await fetch("/api/cliente/turnos", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ autoId, tipoServicioId: servicioSel.id, fechaInicio: slotSel.fechaInicio }), body: JSON.stringify({
autoId,
tipoServicioId: servicioSel.id,
jefeMecanicoId: jefeSel.id,
fechaInicio: slotSel.fechaInicio,
kilometros: Number(kilometros),
}),
}); });
const data = await res.json(); const data = await res.json();
setReservando(false); setReservando(false);
if (res.ok) { if (res.ok) {
setTurnoCreado(data); setTurnoCreado(data);
setDiaSeleccionado(null);
setStep("confirmado"); setStep("confirmado");
} else {
setReservaError(data?.error || "Ocurrió un error al reservar. Intentá de nuevo.");
} }
} }
function formatSlot(slot: Slot) { // Navegación de mes
const inicio = new Date(slot.fechaInicio); const hoyStr = hoyArg();
const fin = new Date(slot.fechaFin); const mesActual = { year: hoy.getFullYear(), month: hoy.getMonth() };
const dia = inicio.toLocaleDateString("es-AR", { weekday: "long", day: "numeric", month: "long" }); const puedeRetroceder = mesVista.year > mesActual.year || (mesVista.year === mesActual.year && mesVista.month > mesActual.month);
const h1 = inicio.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
const h2 = fin.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" }); function prevMes() {
return { dia: dia.charAt(0).toUpperCase() + dia.slice(1), horas: `${h1} - ${h2}` }; if (!puedeRetroceder) return;
setMesVista(m => {
if (m.month === 0) return { year: m.year - 1, month: 11 };
return { year: m.year, month: m.month - 1 };
});
} }
function nextMes() {
setMesVista(m => {
if (m.month === 11) return { year: m.year + 1, month: 0 };
return { year: m.year, month: m.month + 1 };
});
}
const celdas = celdasMes(mesVista.year, mesVista.month);
const slotsDelDia = diaSeleccionado ? (slotsPorDia[diaSeleccionado] ?? []) : [];
if (step === "confirmado" && turnoCreado) { if (step === "confirmado" && turnoCreado) {
const { dia, horas } = formatSlot({ fechaInicio: turnoCreado.fechaInicio, fechaFin: turnoCreado.fechaFin });
return ( return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center gap-4"> <div className="flex flex-col items-center justify-center min-h-[50vh] text-center gap-4">
<div className="text-5xl">✅</div> <div className="text-5xl">✅</div>
<h1 className="text-2xl font-bold text-gray-800">¡Turno reservado!</h1> <h1 className="text-2xl font-bold text-white">¡Turno reservado!</h1>
<Card className="text-left w-full max-w-sm"> <Card className="text-left w-full max-w-sm">
<p className="text-sm text-gray-500 mb-1">Servicio</p> <p className="text-sm text-gray-500 mb-1">Servicio</p>
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p> <p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
{turnoCreado.jefeMecanico && (
<>
<p className="text-sm text-gray-500 mb-1">Jefe mecánico</p>
<p className="font-semibold mb-3">{turnoCreado.jefeMecanico.nombre}</p>
</>
)}
<p className="text-sm text-gray-500 mb-1">Fecha</p> <p className="text-sm text-gray-500 mb-1">Fecha</p>
<p className="font-semibold mb-1">{dia}</p> <p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
<p className="text-gray-600">{horas}</p> timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long"
})}</p>
<p className="text-gray-600">{horaArg(turnoCreado.fechaInicio)} – {horaArg(turnoCreado.fechaFin)}</p>
</Card> </Card>
<Link href="/cliente/dashboard"> <Link href="/cliente/dashboard"><Button variant="secondary">← Volver a mis autos</Button></Link>
<Button variant="secondary">← Volver a mis autos</Button>
</Link>
</div> </div>
); );
} }
@@ -103,30 +235,30 @@ function TurnosContent() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{step === "slot" ? ( {step === "calendario" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
) : step === "jefe" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button> <Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
) : ( ) : (
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link> <Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
)} )}
<h1 className="text-2xl font-bold text-gray-800"> <h1 className="text-xl sm:text-2xl font-bold text-white">
{step === "servicio" ? "Elegí el servicio" : "Elegí el horario"} {step === "servicio" && "Elegí el servicio"}
{step === "jefe" && "Elegí el jefe mecánico"}
{step === "calendario" && `${servicioSel?.nombre}${jefeSel ? ` · ${jefeSel.nombre}` : ""}`}
</h1> </h1>
</div> </div>
{/* STEP: servicio */}
{step === "servicio" && ( {step === "servicio" && (
<> <>
<div className="grid gap-3"> <div className="grid gap-3">
{servicios.map((s) => ( {servicios.map(s => (
<button <button key={s.id} onClick={() => seleccionarServicio(s)}
key={s.id} className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between">
onClick={() => seleccionarServicio(s)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
>
<div> <div>
<p className="font-medium text-gray-800">{s.nombre}</p> <p className="font-medium text-gray-800">{s.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5"> <p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
Duración: {s.duracionHoras === 1 ? "1 hora" : `${s.duracionHoras} horas`}
</p>
</div> </div>
<span className="text-gray-400 text-lg">→</span> <span className="text-gray-400 text-lg">→</span>
</button> </button>
@@ -136,70 +268,185 @@ function TurnosContent() {
{!loadingTurnos && misTurnos.length > 0 && ( {!loadingTurnos && misTurnos.length > 0 && (
<Card title="Mis turnos para este auto"> <Card title="Mis turnos para este auto">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{misTurnos.map((t) => { {misTurnos.map(t => (
const { dia, horas } = formatSlot({ fechaInicio: t.fechaInicio, fechaFin: t.fechaFin });
return (
<div key={t.id} className="flex items-center justify-between flex-wrap gap-2"> <div key={t.id} className="flex items-center justify-between flex-wrap gap-2">
<div> <div>
<p className="text-sm font-medium">{t.tipoServicio.nombre}</p> <p className="text-sm font-medium">{t.tipoServicio.nombre}</p>
<p className="text-xs text-gray-500">{dia} · {horas}</p> <p className="text-xs text-gray-500">
{new Date(t.fechaInicio).toLocaleDateString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires",
weekday: "short", day: "numeric", month: "short"
})} · {horaArg(t.fechaInicio)} – {horaArg(t.fechaFin)}
</p>
</div> </div>
<Badge estado={t.estado} /> <Badge estado={t.estado} />
</div> </div>
); ))}
})}
</div> </div>
</Card> </Card>
)} )}
</> </>
)} )}
{step === "slot" && ( {/* STEP: jefe */}
<div className="flex flex-col gap-4"> {step === "jefe" && (
{loadingSlots ? ( <div className="flex flex-col gap-3">
<div className="text-center py-10 text-gray-400">Buscando horarios disponibles...</div> {loadingJefes ? (
) : slots.length === 0 ? ( <p className="text-center text-sm text-gray-200">Cargando jefes...</p>
) : jefes.length === 0 ? (
<Card> <Card>
<p className="text-center text-gray-400 py-4"> <p className="text-sm text-gray-600 text-center">
No hay horarios disponibles en los próximos 14 días. Consultá al taller. No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
</p> </p>
</Card> </Card>
) : ( ) : (
<> jefes.map((j) => (
<p className="text-sm text-gray-500"> <button
Servicio: <strong>{servicioSel?.nombre}</strong> key={j.id}
</p> onClick={() => seleccionarJefe(j)}
<div className="grid gap-2"> className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
{slots.map((slot, i) => { >
const { dia, horas } = formatSlot(slot); <div>
const sel = slotSel?.fechaInicio === slot.fechaInicio; <p className="font-medium text-gray-800">{j.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Ver disponibilidad</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
))
)}
</div>
)}
{/* STEP: calendario */}
{step === "calendario" && (
<div className="flex flex-col gap-4">
{loadingSlots && (
<p className="text-center text-sm text-gray-200">Cargando disponibilidad...</p>
)}
{/* Cabecera del mes */}
<div className="flex items-center justify-between">
<button onClick={prevMes} disabled={!puedeRetroceder}
className={`p-2 rounded-lg transition ${puedeRetroceder ? "hover:bg-white/20 text-gray-100" : "text-gray-400 cursor-default"}`}>
‹
</button>
<span className="font-semibold text-white">
{MESES[mesVista.month]} {mesVista.year}
</span>
<button onClick={nextMes} className="p-2 rounded-lg hover:bg-white/20 text-gray-100 transition">
›
</button>
</div>
{/* Grid del calendario */}
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
{/* Headers días */}
<div className="grid grid-cols-7 border-b border-gray-100">
{DIAS_HEADER.map(d => (
<div key={d} className={`text-center text-xs font-medium py-2 ${d === "Sáb" || d === "Dom" ? "text-gray-300" : "text-gray-500"}`}>
{d}
</div>
))}
</div>
{/* Celdas */}
<div className="grid grid-cols-7">
{celdas.map((dia, i) => {
if (dia === null) return <div key={`e-${i}`} className="aspect-square" />;
const dateStr = `${mesVista.year}-${String(mesVista.month + 1).padStart(2, "0")}-${String(dia).padStart(2, "0")}`;
const finde = esFinDeSemana(mesVista.year, mesVista.month, dia);
const pasado = dateStr < hoyStr;
const tieneSlots = !!slotsPorDia[dateStr];
const seleccionado = diaSeleccionado === dateStr;
const esHoy = dateStr === hoyStr;
const deshabilitado = finde || pasado || (!tieneSlots && !loadingSlots);
return ( return (
<button <button
key={i} key={dateStr}
onClick={() => setSlotSel(slot)} disabled={deshabilitado}
className={`text-left rounded-xl p-4 border transition ${ onClick={() => seleccionarDia(dateStr)}
sel className={`aspect-square flex items-center justify-center text-sm relative transition
? "border-blue-500 bg-blue-50" ${deshabilitado ? "cursor-default" : "hover:bg-blue-50 cursor-pointer"}
: "border-gray-200 bg-white hover:border-blue-300" ${seleccionado ? "bg-blue-600 text-white rounded-full font-semibold" : ""}
}`} ${!seleccionado && esHoy ? "font-bold text-blue-600" : ""}
${!seleccionado && finde ? "text-gray-300" : ""}
${!seleccionado && pasado && !finde ? "text-gray-300" : ""}
${!seleccionado && !deshabilitado ? "text-gray-800" : ""}
`}
> >
<p className="font-medium text-gray-800">{dia}</p> {dia}
<p className="text-sm text-gray-500">{horas}</p> {tieneSlots && !seleccionado && !deshabilitado && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-blue-500" />
)}
</button> </button>
); );
})} })}
</div> </div>
<Button </div>
onClick={reservar}
disabled={!slotSel} {!loadingSlots && slots.length === 0 && (
loading={reservando} <p className="text-center text-sm text-gray-200">No hay turnos disponibles en los próximos 60 días.</p>
className="w-full mt-2" )}
> </div>
Confirmar turno )}
{/* MODAL de horarios */}
{diaSeleccionado && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => setDiaSeleccionado(null)} />
<div className="relative bg-white w-full sm:w-96 rounded-t-2xl sm:rounded-2xl shadow-xl p-5 flex flex-col gap-4 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-800">
{new Date(`${diaSeleccionado}T12:00:00`).toLocaleDateString("es-AR", {
weekday: "long", day: "numeric", month: "long"
})}
</h2>
<button onClick={() => setDiaSeleccionado(null)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
{slotsDelDia.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No hay horarios disponibles este día.</p>
) : (
<>
<div className="grid grid-cols-3 gap-2">
{slotsDelDia.map((s, i) => {
const sel = slotSel?.fechaInicio === s.fechaInicio;
return (
<button key={i} onClick={() => setSlotSel(s)}
className={`rounded-xl py-2 text-sm font-medium border transition
${sel ? "bg-blue-600 text-white border-blue-600" : "border-gray-200 text-gray-700 hover:border-blue-400"}`}>
{horaArg(s.fechaInicio)}
</button>
);
})}
</div>
{slotSel && (
<div className="flex flex-col gap-3 border-t border-gray-100 pt-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kilometraje actual <span className="text-red-500">*</span>
</label>
<input
type="text" inputMode="numeric" placeholder="Ej: 85000"
value={kilometros}
onChange={e => handleKilometros(e.target.value)}
className={`w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 ${kmError ? "border-red-400 focus:ring-red-400" : "border-gray-300 focus:ring-blue-500"}`}
/>
{kmError && <p className="text-xs text-red-500 mt-1">{kmError}</p>}
</div>
<Button onClick={reservar} disabled={!kilometros || !!kmError} loading={reservando} className="w-full">
Confirmar turno · {horaArg(slotSel.fechaInicio)} – {horaArg(slotSel.fechaFin)}
</Button> </Button>
{reservaError && <p className="text-xs text-red-500 mt-2 text-center">{reservaError}</p>}
</div>
)}
</> </>
)} )}
</div> </div>
</div>
)} )}
</div> </div>
); );
@@ -207,7 +454,7 @@ function TurnosContent() {
export default function TurnosPage() { export default function TurnosPage() {
return ( return (
<Suspense fallback={<div className="text-center py-10 text-gray-400">Cargando...</div>}> <Suspense fallback={<div className="text-center py-10 text-gray-200">Cargando...</div>}>
<TurnosContent /> <TurnosContent />
</Suspense> </Suspense>
); );
+8 -9
View File
@@ -3,20 +3,19 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
--background: #ffffff;
--foreground: #171717; --foreground: #171717;
} }
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body { body {
color: var(--foreground); color: var(--foreground);
background: var(--background); background-image:
linear-gradient(rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.35)),
url('/tallermg.jpeg');
background-size: cover;
background-position: center;
background-attachment: fixed;
background-repeat: no-repeat;
min-height: 100vh;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import Card from "@/components/ui/Card";
import Badge from "@/components/ui/Badge";
interface Turno {
id: string;
fechaInicio: string;
fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
comentario: string | null;
kilometros: number;
tipoServicio: { nombre: string };
auto: { marca: string; modelo: string; patente: string };
cliente: { nombre: string; apellido: string; telefono: string };
}
function hoyArg() {
return new Date().toLocaleDateString("sv-SE", { timeZone: "America/Argentina/Buenos_Aires" });
}
function formatHora(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires",
hour: "2-digit",
minute: "2-digit",
});
}
export default function JefeDashboard() {
const [fecha, setFecha] = useState<string>(() => hoyArg());
const [turnos, setTurnos] = useState<Turno[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { cargar(); }, [fecha]);
async function cargar() {
setLoading(true);
const res = await fetch(`/api/jefe/turnos?fecha=${fecha}`);
const data = await res.json();
setTurnos(Array.isArray(data) ? data : []);
setLoading(false);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-xl sm:text-2xl font-bold text-white">Turnos del día</h2>
<input
type="date"
value={fecha}
onChange={(e) => setFecha(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 w-full sm:w-auto"
/>
</div>
{loading ? (
<div className="text-center py-10 text-gray-200">Cargando...</div>
) : turnos.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">No tenés turnos para esta fecha.</p>
</Card>
) : (
<div className="flex flex-col gap-4">
{turnos.map((t) => (
<Card key={t.id}>
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between flex-wrap gap-2">
<div>
<p className="font-semibold text-gray-800">
{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}
</p>
<p className="text-sm text-gray-600 mt-0.5">{t.tipoServicio.nombre}</p>
</div>
<Badge estado={t.estado} />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
<span className="text-gray-400">Cliente</span>
<span>{t.cliente.nombre} {t.cliente.apellido}</span>
<span className="text-gray-400">Teléfono</span>
<span>{t.cliente.telefono}</span>
<span className="text-gray-400">Auto</span>
<span>{t.auto.marca} {t.auto.modelo}</span>
<span className="text-gray-400">Patente</span>
<span className="font-mono">{t.auto.patente}</span>
<span className="text-gray-400">Kilometraje</span>
<span>{t.kilometros.toLocaleString("es-AR")} km</span>
</div>
{t.comentario && (
<div className="text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
<p className="text-gray-400 mb-1">Comentario</p>
<p>{t.comentario}</p>
</div>
)}
</div>
</Card>
))}
</div>
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
export default function JefeLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
async function cerrarSesion() {
await fetch("/api/auth/jefe-logout", { method: "POST" });
router.push("/jefe");
}
if (pathname === "/jefe") return <>{children}</>;
return (
<div className="min-h-screen flex flex-col">
<div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col py-6 gap-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-lg font-semibold text-white">🔧 Mi agenda</h1>
<button
onClick={cerrarSesion}
className="text-sm text-gray-300 hover:text-red-300 transition px-3 py-1.5 rounded-lg hover:bg-white/10"
>
Cerrar sesión
</button>
</div>
<main className="flex-1">{children}</main>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card";
export default function JefeLoginPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
function set(f: string) {
return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((v) => ({ ...v, [f]: e.target.value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/auth/jefe-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
setLoading(false);
if (!res.ok) { setError(data.error || "Credenciales incorrectas"); return; }
router.push("/jefe/dashboard");
}
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<Card className="w-full max-w-sm mx-4">
<div className="text-center mb-6">
<div className="text-4xl mb-2">🔧</div>
<h1 className="text-xl font-bold text-gray-800">Agenda del jefe mecánico</h1>
<p className="text-sm text-gray-500 mt-1">Ingresá con tus credenciales</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input id="email" type="email" label="Email" placeholder="jefe@taller.com" value={form.email} onChange={set("email")} required />
<Input id="password" type="password" label="Contraseña" placeholder="••••••••" value={form.password} onChange={set("password")} required />
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
<Button type="submit" loading={loading} className="w-full">Ingresar</Button>
</form>
</Card>
</div>
);
}
+2 -2
View File
@@ -14,8 +14,8 @@ const geistMono = localFont({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Taller MG",
description: "Generated by create next app", description: "Sistema de turnos - Taller MG",
}; };
export default function RootLayout({ export default function RootLayout({
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface ComboboxProps {
id?: string;
label?: string;
options: string[];
value: string;
onChange: (value: string) => void;
onSelect?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
}
export default function Combobox({
id, label, options, value, onChange, onSelect,
placeholder, disabled, required, className,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const filtered = value
? options.filter((o) => o.toLowerCase().includes(value.toLowerCase())).slice(0, 60)
: options.slice(0, 60);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
function handleSelect(option: string) {
onChange(option);
onSelect?.(option);
setOpen(false);
}
return (
<div className={`relative ${className ?? ""}`} ref={ref}>
{label && (
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
<input
id={id}
type="text"
value={value}
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
required={required}
autoComplete="off"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
/>
{open && !disabled && filtered.length > 0 && (
<ul className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-52 overflow-y-auto">
{filtered.map((option) => (
<li
key={option}
onMouseDown={() => handleSelect(option)}
className="px-3 py-2 text-sm cursor-pointer hover:bg-blue-50 hover:text-blue-700"
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
+8
View File
@@ -19,6 +19,14 @@ export async function crearSesionCliente(clienteId: string): Promise<string> {
return token; return token;
} }
export async function crearSesionJefe(jefeId: string): Promise<string> {
const token = await new SignJWT({ sub: jefeId, role: "jefe" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("8h")
.sign(SECRET);
return token;
}
export async function verificarSesion( export async function verificarSesion(
cookieName: string cookieName: string
): Promise<{ sub: string; role: string } | null> { ): Promise<{ sub: string; role: string } | null> {
+102
View File
@@ -0,0 +1,102 @@
import crypto from "crypto";
const SHEET_ID = process.env.GOOGLE_SHEET_ID!;
const SHEET_TAB = "Hoja 1";
async function getAccessToken(): Promise<string> {
const clientEmail = process.env.GOOGLE_CLIENT_EMAIL!;
const privateKey = process.env.GOOGLE_PRIVATE_KEY!.replace(/\\n/g, "\n");
const now = Math.floor(Date.now() / 1000);
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
const payload = Buffer.from(
JSON.stringify({
iss: clientEmail,
scope: "https://www.googleapis.com/auth/spreadsheets",
aud: "https://oauth2.googleapis.com/token",
exp: now + 3600,
iat: now,
})
).toString("base64url");
const sign = crypto.createSign("RSA-SHA256");
sign.update(`${header}.${payload}`);
const signature = sign.sign(privateKey, "base64url");
const jwt = `${header}.${payload}.${signature}`;
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
}),
});
const data = await res.json() as { access_token: string };
return data.access_token;
}
export interface FilaTaller {
patente: string;
kilometros: number;
marca: string;
modelo: string;
anio: number;
nombre: string;
apellido: string;
email: string;
telefono: string;
fecha: Date;
trabajo: string;
comentario?: string | null;
}
export async function agregarFilaSheet(fila: FilaTaller) {
const token = await getAccessToken();
const fecha = fila.fecha.toLocaleDateString("es-AR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
// K=GASTOS, L=VARIOS, M=M DE OBRA son manuales — se dejan vacíos
const valores = [
fila.patente,
fila.kilometros,
fila.marca,
fila.modelo,
fila.anio,
fila.nombre,
fila.apellido,
fecha,
fila.email,
fila.telefono,
"", // K: GASTOS
"", // L: VARIOS
"", // M: M DE OBRA
fila.comentario ?? "", // N: Comentarios
];
// Anclamos en A1 para que la API siempre busque desde el inicio de la tabla.
// INSERT_ROWS fuerza una fila nueva, evita que desplace los datos cuando hay
// celdas ocupadas en columnas manuales (GASTOS, VARIOS, etc.).
const range = encodeURIComponent(`${SHEET_TAB}!A1`);
const url = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ values: [valores] }),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Sheets API error: ${err}`);
}
}
+14 -7
View File
@@ -1,21 +1,28 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { Resend } from "resend"; import nodemailer from "nodemailer";
import crypto from "crypto"; import crypto from "crypto";
const resend = new Resend(process.env.RESEND_API_KEY); const transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: {
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_APP_PASSWORD,
},
});
export async function enviarMagicLink(email: string): Promise<{ ok: boolean; error?: string }> { export async function enviarMagicLink(email: string): Promise<{ ok: boolean; error?: string }> {
const cliente = await prisma.cliente.findUnique({ where: { email } }); const cliente = await prisma.cliente.findUnique({ where: { email } });
if (!cliente) return { ok: false, error: "Email no registrado" }; if (!cliente) return { ok: false, error: "Email no registrado" };
// Invalidar tokens anteriores no usados
await prisma.magicToken.updateMany({ await prisma.magicToken.updateMany({
where: { clienteId: cliente.id, usado: false }, where: { clienteId: cliente.id, usado: false },
data: { usado: true }, data: { usado: true },
}); });
const token = crypto.randomBytes(32).toString("hex"); const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutos const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await prisma.magicToken.create({ await prisma.magicToken.create({
data: { clienteId: cliente.id, token, expiresAt }, data: { clienteId: cliente.id, token, expiresAt },
@@ -23,9 +30,9 @@ export async function enviarMagicLink(email: string): Promise<{ ok: boolean; err
const link = `${process.env.MAGIC_LINK_BASE_URL}/cliente/auth?token=${token}`; const link = `${process.env.MAGIC_LINK_BASE_URL}/cliente/auth?token=${token}`;
await resend.emails.send({ await transporter.sendMail({
from: "Taller <onboarding@resend.dev>", from: `"Taller" <${process.env.GMAIL_USER}>`,
to: "jpbeldomenico@gmail.com", to: email,
subject: "Tu link de acceso al taller", subject: "Tu link de acceso al taller",
html: ` html: `
<h2>Hola ${cliente.nombre}!</h2> <h2>Hola ${cliente.nombre}!</h2>
+70
View File
@@ -0,0 +1,70 @@
export const MARCAS_MODELOS: Record<string, string[]> = {
"Acura": ["ILX", "Integra", "MDX", "RDX", "TLX"],
"Alfa Romeo": ["147", "156", "159", "Brera", "Giulia", "Giulietta", "GT", "MiTo", "Spider", "Stelvio", "Tonale"],
"Aston Martin": ["DB11", "DB9", "DBX", "DBS", "Rapide", "Vantage", "Virage"],
"Audi": ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "e-tron", "e-tron GT", "Q2", "Q3", "Q4 e-tron", "Q5", "Q7", "Q8", "R8", "RS3", "RS4", "RS5", "RS6", "RS7", "S3", "S4", "S5", "S6", "S7", "S8", "SQ5", "SQ7", "SQ8", "TT", "TT RS", "TTS"],
"Bentley": ["Bentayga", "Continental GT", "Continental GTC", "Flying Spur", "Mulsanne"],
"BMW": ["116i", "118i", "120i", "218i", "220i", "316i", "318i", "320i", "325i", "330i", "335i", "418i", "420i", "430i", "440i", "520i", "525i", "530i", "540i", "545i", "550i", "630i", "740i", "750i", "M2", "M3", "M4", "M5", "M8", "X1", "X2", "X3", "X4", "X5", "X6", "X7", "Z4"],
"BYD": ["Atto 3", "Dolphin", "Han", "Seal", "Song", "Song Plus", "Tang", "Yuan Plus"],
"Buick": ["Enclave", "Encore", "Encore GX", "Envision", "LaCrosse", "Regal"],
"Cadillac": ["ATS", "CT4", "CT5", "CTS", "Escalade", "SRX", "XT4", "XT5", "XT6"],
"Chery": ["Arrizo 5", "Arrizo 5 Pro", "Arrizo 6", "Arrizo 6 Pro", "Tiggo 2", "Tiggo 2 Pro", "Tiggo 3", "Tiggo 3X", "Tiggo 4", "Tiggo 4 Pro", "Tiggo 5X", "Tiggo 7", "Tiggo 7 Pro", "Tiggo 8", "Tiggo 8 Pro"],
"Chevrolet": ["Agile", "Blazer", "Bolt", "Camaro", "Captiva", "Colorado", "Corvette", "Cruze", "Equinox", "Montana", "Onix", "Onix Plus", "S10", "Silverado", "Spin", "Tahoe", "Tracker", "Trailblazer", "Traverse", "Trax"],
"Chrysler": ["300", "300C", "Pacifica", "Sebring", "Voyager"],
"Citroën": ["Berlingo", "C-Elysée", "C1", "C2", "C3", "C3 Aircross", "C4", "C4 Cactus", "C5", "C5 Aircross", "C5 X", "C6", "C8", "DS3", "DS4", "DS5", "Jumper", "Jumpy", "Nemo", "SpaceTourer", "Xantia", "Xsara"],
"Dacia": ["Duster", "Jogger", "Logan", "Sandero", "Spring"],
"Dodge": ["Challenger", "Charger", "Dart", "Durango", "Journey", "RAM 1000", "RAM 1500", "RAM 2500", "Viper"],
"DS": ["DS 3", "DS 3 Crossback", "DS 4", "DS 5", "DS 7", "DS 7 Crossback", "DS 9"],
"Ferrari": ["296 GTB", "296 GTS", "812 Competizione", "812 Superfast", "F8 Spider", "F8 Tributo", "GTC4Lusso", "Portofino", "Portofino M", "Purosangue", "Roma", "SF90 Spider", "SF90 Stradale"],
"Fiat": ["124 Spider", "500", "500L", "500X", "Argo", "Bravo", "Cronos", "Doblo", "Ducato", "Fiorino", "Linea", "Mobi", "Palio", "Punto", "Qubo", "Siena", "Strada", "Toro", "Tipo", "Uno"],
"Ford": ["Bronco", "Bronco Sport", "C-Max", "EcoSport", "Edge", "Escape", "Expedition", "Explorer", "F-150", "F-150 Lightning", "Fiesta", "Focus", "Fusion", "Galaxy", "Ka", "Ka+", "Kuga", "Maverick", "Mondeo", "Mustang", "Mustang Mach-E", "Puma", "Ranger", "S-Max", "Territory", "Transit", "Transit Connect", "Transit Custom"],
"Geely": ["Atlas", "Coolray", "Emgrand", "Monjaro", "Okavango", "Preface", "Tugella"],
"Genesis": ["G70", "G80", "G90", "GV70", "GV80"],
"GMC": ["Acadia", "Canyon", "Envoy", "Sierra", "Terrain", "Yukon"],
"Great Wall": ["Cannon", "Haval H1", "Haval H2", "Haval H4", "Haval H6", "Haval H9", "Haval Jolion", "Ora Funky Cat", "Poer", "Wingle"],
"Haval": ["F7", "H1", "H2", "H4", "H6", "H9", "Jolion"],
"Honda": ["Accord", "City", "Civic", "CR-V", "CR-Z", "Element", "Fit", "HR-V", "Insight", "Jazz", "Legend", "Odyssey", "Passport", "Pilot", "Ridgeline", "WR-V"],
"IKA-Renault": ["Carabela", "Jeep", "Rambler", "Torino"],
"Hummer": ["EV", "H1", "H2", "H3"],
"Hyundai": ["Accent", "Azera", "Creta", "Elantra", "Getz", "HB20", "i10", "i20", "i30", "i40", "Ioniq", "Ioniq 5", "Ioniq 6", "ix35", "Kona", "Santa Cruz", "Santa Fe", "Sonata", "Terracan", "Tucson", "Veloster", "Venue"],
"Infiniti": ["Q30", "Q40", "Q50", "Q60", "Q70", "QX30", "QX50", "QX55", "QX60", "QX70", "QX80"],
"JAC": ["E10X", "J7", "JS4", "S2", "S3", "S4", "S7", "T6", "T8"],
"Jaguar": ["E-Pace", "F-Pace", "F-Type", "I-Pace", "XE", "XF", "XJ"],
"Jeep": ["Avenger", "Cherokee", "Commander", "Compass", "Gladiator", "Grand Cherokee", "Grand Cherokee 4xe", "Renegade", "Wagoneer", "Wrangler"],
"Kia": ["Carnival", "Cerato", "Ceed", "EV6", "Picanto", "Rio", "Seltos", "Sonet", "Sorento", "Soul", "Sportage", "Stinger", "Stonic", "Telluride"],
"Lamborghini": ["Aventador", "Huracán", "Revuelto", "Urus"],
"Land Rover": ["Defender", "Discovery", "Discovery Sport", "Freelander", "Range Rover", "Range Rover Evoque", "Range Rover Sport", "Range Rover Velar"],
"Lexus": ["CT", "ES", "GS", "GX", "IS", "LC", "LS", "LX", "NX", "RC", "RX", "UX"],
"Lifan": ["530", "620", "720", "820", "X50", "X60", "X70", "X80"],
"Lincoln": ["Aviator", "Corsair", "MKC", "MKT", "MKX", "MKZ", "Nautilus", "Navigator"],
"Lotus": ["Eletre", "Emira", "Evija"],
"Maserati": ["Ghibli", "GranCabrio", "GranTurismo", "Grecale", "Levante", "MC20", "Quattroporte"],
"Mazda": ["2", "3", "6", "BT-50", "CX-3", "CX-30", "CX-5", "CX-50", "CX-60", "CX-9", "MX-30", "MX-5", "RX-8"],
"McLaren": ["570S", "600LT", "650S", "720S", "750S", "Artura", "GT"],
"Mercedes-Benz": ["A 180", "A 200", "A 250", "AMG GT", "B 200", "C 180", "C 200", "C 300", "CLA 200", "CLA 250", "CLC 200", "CLE 200", "CLK 200", "CLS 350", "E 200", "E 250", "E 300", "E 350", "G 500", "G 63 AMG", "GLA 200", "GLA 250", "GLB 200", "GLC 200", "GLC 300", "GLE 350", "GLS 400", "ML 350", "S 400", "S 500", "Sprinter", "Vito"],
"MG": ["EZS", "GS", "HS", "MG3", "MG5", "RX5", "ZS", "ZS EV"],
"Mini": ["Clubman", "Convertible", "Cooper", "Cooper S", "Countryman", "JCW", "Paceman"],
"Mitsubishi": ["ASX", "Eclipse Cross", "Galant", "L200", "Lancer", "Montero", "Outlander", "Pajero", "Pajero Sport"],
"Nissan": ["370Z", "Frontier", "GT-R", "Juke", "Kicks", "Leaf", "March", "Micra", "Murano", "Note", "Pathfinder", "Qashqai", "Sentra", "Tiida", "Versa", "X-Trail"],
"Opel": ["Astra", "Corsa", "Crossland", "Grandland", "Insignia", "Meriva", "Mokka", "Vectra", "Vivaro", "Zafira"],
"Peugeot": ["1007", "107", "108", "2008", "206", "207", "208", "3008", "301", "307", "308", "408", "5008", "508", "Boxer", "Expert", "Partner", "Rifter"],
"Porsche": ["718 Boxster", "718 Cayman", "911", "Cayenne", "Macan", "Panamera", "Taycan"],
"RAM": ["700", "1000", "1500", "2500", "3500", "Promaster"],
"Renault": ["Alaskan", "Arkana", "Austral", "Captur", "Clio", "Duster", "Fluence", "Kadjar", "Kangoo", "Koleos", "Laguna", "Logan", "Master", "Megane", "Rafale", "Sandero", "Scenic", "Symbol", "Trafic", "Zoe"],
"Rolls-Royce": ["Cullinan", "Dawn", "Ghost", "Phantom", "Silver Shadow", "Spectre", "Wraith"],
"SEAT": ["Arona", "Ateca", "Ibiza", "León", "Tarraco"],
"Škoda": ["Fabia", "Kamiq", "Karoq", "Kodiaq", "Octavia", "Scala", "Superb"],
"Smart": ["EQ fortwo", "EQ forfour", "forfour", "fortwo"],
"Subaru": ["Ascent", "BRZ", "Crosstrek", "Forester", "Impreza", "Legacy", "Outback", "Solterra", "WRX", "XV"],
"Suzuki": ["Baleno", "Celerio", "Dzire", "Ertiga", "Grand Vitara", "Ignis", "Jimny", "S-Cross", "Splash", "SX4", "Swift", "Vitara"],
"Tesla": ["Cybertruck", "Model 3", "Model S", "Model X", "Model Y", "Roadster"],
"Toyota": ["4Runner", "86", "Aqua", "Avalon", "C-HR", "Camry", "Corolla", "Corolla Cross", "Crown", "Etios", "Fortuner", "GR86", "Hilux", "Land Cruiser", "Land Cruiser Prado", "Prius", "RAV4", "Rush", "Sequoia", "SW4", "Tacoma", "Tundra", "Vios", "Yaris"],
"Volkswagen": ["Amarok", "Bora", "Caddy", "Crafter", "Fox", "Gol", "Golf", "Jetta", "Nivus", "Passat", "Polo", "Saveiro", "Suran", "T-Cross", "T-Roc", "Taigo", "Taos", "Tiguan", "Touareg", "Touran", "Transporter", "Up!", "Vento", "Virtus"],
"Volvo": ["C30", "C40", "S40", "S60", "S80", "S90", "V40", "V60", "V90", "XC40", "XC60", "XC70", "XC90"],
};
export const MARCAS = Object.keys(MARCAS_MODELOS).sort((a, b) => a.localeCompare(b));
export function getModelos(marca: string): string[] {
return MARCAS_MODELOS[marca] ?? [];
}
+87 -36
View File
@@ -1,92 +1,143 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
const SLOTS_PARALELOS = 4; // 2 jefes × 2 autos c/u const INICIO_H = 8;
const HORA_INICIO = 8; const INICIO_M = 30;
const HORA_FIN = 18; const FIN_H = 17;
const FIN_M = 0;
const INICIO_MINUTOS = INICIO_H * 60 + INICIO_M; // 510
const FIN_MINUTOS = FIN_H * 60 + FIN_M; // 1020
// Argentina es siempre UTC-3 (sin horario de verano desde 2008)
const ARG_OFFSET_MIN = -180;
// Minutos del día en hora Argentina (0-1439)
function minutosArgentina(fecha: Date): number {
const utcMin = fecha.getUTCHours() * 60 + fecha.getUTCMinutes();
return ((utcMin + ARG_OFFSET_MIN) + 1440) % 1440;
}
// Día de la semana en Argentina (0=Dom, 6=Sab)
function diaArgentina(fecha: Date): number {
const arg = new Date(fecha.getTime() + ARG_OFFSET_MIN * 60 * 1000);
return arg.getUTCDay();
}
// Componentes de fecha Argentina (para construir fechas UTC)
function fechaArgentina(fecha: Date): { year: number; month: number; date: number } {
const arg = new Date(fecha.getTime() + ARG_OFFSET_MIN * 60 * 1000);
return { year: arg.getUTCFullYear(), month: arg.getUTCMonth(), date: arg.getUTCDate() };
}
// Devuelve la fecha UTC correspondiente a las 8:30 Argentina del día que contiene `fecha`
function inicioJornada(fecha: Date): Date {
const { year, month, date } = fechaArgentina(fecha);
// 8:30 Argentina + 3h = 11:30 UTC
return new Date(Date.UTC(year, month, date, INICIO_H - ARG_OFFSET_MIN / 60, INICIO_M, 0, 0));
}
function esDiaHabil(fecha: Date): boolean { function esDiaHabil(fecha: Date): boolean {
const dia = fecha.getDay(); // 0=Dom, 6=Sab const dia = diaArgentina(fecha);
return dia >= 1 && dia <= 5; return dia >= 1 && dia <= 5;
} }
function horasDentroDeJornada(inicio: Date, fin: Date): boolean { function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
const h1 = inicio.getHours() + inicio.getMinutes() / 60; const iMin = minutosArgentina(inicio);
const h2 = fin.getHours() + fin.getMinutes() / 60; const fMin = minutosArgentina(fin);
return h1 >= HORA_INICIO && h2 <= HORA_FIN; const iArg = fechaArgentina(inicio);
const fArg = fechaArgentina(fin);
const mismodia =
iArg.year === fArg.year && iArg.month === fArg.month && iArg.date === fArg.date;
return mismodia && iMin >= INICIO_MINUTOS && fMin <= FIN_MINUTOS;
} }
// Devuelve cuántos turnos activos se superponen con el rango dado async function turnosSolapados(
async function turnosSolapados(inicio: Date, fin: Date): Promise<number> { inicio: Date,
fin: Date,
jefeMecanicoId: string
): Promise<number> {
const count = await prisma.turno.count({ const count = await prisma.turno.count({
where: { where: {
jefeMecanicoId,
estado: { in: ["PENDIENTE", "CONFIRMADO"] }, estado: { in: ["PENDIENTE", "CONFIRMADO"] },
AND: [ AND: [{ fechaInicio: { lt: fin } }, { fechaFin: { gt: inicio } }],
{ fechaInicio: { lt: fin } },
{ fechaFin: { gt: inicio } },
],
}, },
}); });
return count; return count;
} }
async function obtenerJefeActivo(jefeMecanicoId: string) {
return prisma.jefeMecanico.findUnique({
where: { id: jefeMecanicoId },
select: { slotsParalelos: true, activo: true },
});
}
export interface SlotDisponible { export interface SlotDisponible {
fechaInicio: Date; fechaInicio: Date;
fechaFin: Date; fechaFin: Date;
} }
// Busca los próximos N slots disponibles para una duración dada (en horas)
export async function obtenerSlotsDisponibles( export async function obtenerSlotsDisponibles(
jefeMecanicoId: string,
duracionHoras: number, duracionHoras: number,
diasAdelante = 14 diasAdelante = 14,
limite = 300
): Promise<SlotDisponible[]> { ): Promise<SlotDisponible[]> {
const jefe = await obtenerJefeActivo(jefeMecanicoId);
if (!jefe || !jefe.activo) return [];
const cap = jefe.slotsParalelos;
const slots: SlotDisponible[] = []; const slots: SlotDisponible[] = [];
const ahora = new Date(); const ahora = new Date();
const duracionMs = duracionHoras * 60 * 60 * 1000; const duracionMs = duracionHoras * 60 * 60 * 1000;
const fechaLimite = new Date(ahora); const fechaLimite = new Date(ahora.getTime() + diasAdelante * 24 * 60 * 60 * 1000);
fechaLimite.setDate(fechaLimite.getDate() + diasAdelante);
// Avanzar al próximo slot (:00 o :30 Argentina) a partir de ahora
let cursor = new Date(ahora); let cursor = new Date(ahora);
// Empezar desde el siguiente slot redondo (media hora) const argMNow = minutosArgentina(cursor) % 60;
cursor.setMinutes(cursor.getMinutes() >= 30 ? 60 : 30, 0, 0); const avanzarMin = argMNow < 30 ? 30 - argMNow : 60 - argMNow;
cursor = new Date(cursor.getTime() + avanzarMin * 60 * 1000);
cursor.setUTCSeconds(0, 0);
while (cursor < fechaLimite && slots.length < 20) { while (cursor < fechaLimite && slots.length < limite) {
if (!esDiaHabil(cursor)) { if (!esDiaHabil(cursor)) {
cursor.setDate(cursor.getDate() + 1); cursor = inicioJornada(new Date(cursor.getTime() + 24 * 60 * 60 * 1000));
cursor.setHours(HORA_INICIO, 0, 0, 0);
continue; continue;
} }
const hInicio = cursor.getHours() + cursor.getMinutes() / 60; const minActual = minutosArgentina(cursor);
if (hInicio < HORA_INICIO) { if (minActual < INICIO_MINUTOS) {
cursor.setHours(HORA_INICIO, 0, 0, 0); cursor = inicioJornada(cursor);
continue; continue;
} }
const fin = new Date(cursor.getTime() + duracionMs); const fin = new Date(cursor.getTime() + duracionMs);
const hFin = fin.getHours() + fin.getMinutes() / 60;
if (hFin > HORA_FIN) { if (!horasDentroDeJornada(cursor, fin)) {
// Pasar al día siguiente cursor = inicioJornada(new Date(cursor.getTime() + 24 * 60 * 60 * 1000));
cursor.setDate(cursor.getDate() + 1);
cursor.setHours(HORA_INICIO, 0, 0, 0);
continue; continue;
} }
const ocupados = await turnosSolapados(cursor, fin); const ocupados = await turnosSolapados(cursor, fin, jefeMecanicoId);
if (ocupados < SLOTS_PARALELOS) { if (ocupados < cap) {
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) }); slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
} }
// Avanzar 30 minutos
cursor = new Date(cursor.getTime() + 30 * 60 * 1000); cursor = new Date(cursor.getTime() + 30 * 60 * 1000);
} }
return slots; return slots;
} }
export async function slotEstaDisponible(inicio: Date, fin: Date): Promise<boolean> { export async function slotEstaDisponible(
inicio: Date,
fin: Date,
jefeMecanicoId: string
): Promise<boolean> {
if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false; if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false;
const ocupados = await turnosSolapados(inicio, fin); const jefe = await obtenerJefeActivo(jefeMecanicoId);
return ocupados < SLOTS_PARALELOS; if (!jefe || !jefe.activo) return false;
const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId);
return ocupados < jefe.slotsParalelos;
} }
+7 -1
View File
@@ -28,14 +28,20 @@ export async function middleware(req: NextRequest) {
pathname.startsWith("/admin/turnos") || pathname.startsWith("/admin/turnos") ||
pathname.startsWith("/admin/historial") || pathname.startsWith("/admin/historial") ||
pathname.startsWith("/admin/servicios") || pathname.startsWith("/admin/servicios") ||
pathname.startsWith("/admin/jefes") ||
pathname.startsWith("/admin/clientes")) { pathname.startsWith("/admin/clientes")) {
const ok = await verifyCookie(req, "admin_session"); const ok = await verifyCookie(req, "admin_session");
if (!ok) return NextResponse.redirect(new URL("/admin", req.url)); if (!ok) return NextResponse.redirect(new URL("/admin", req.url));
} }
if (pathname.startsWith("/jefe/dashboard")) {
const ok = await verifyCookie(req, "jefe_session");
if (!ok) return NextResponse.redirect(new URL("/jefe", req.url));
}
return NextResponse.next(); return NextResponse.next();
} }
export const config = { export const config = {
matcher: ["/cliente/:path*", "/admin/:path*"], matcher: ["/cliente/:path*", "/admin/:path*", "/jefe/:path*"],
}; };