Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ef9da857 | |||
| 8d1b5da448 | |||
| 5e027d2c8c | |||
| 0792ac3767 | |||
| f79e5ce75c | |||
| a1eedcf8f5 | |||
| 7cc6456f97 | |||
| 457db923d2 | |||
| cc299cdc11 | |||
| 0b769805f9 | |||
| 221aace9f1 | |||
| 0497e936a4 | |||
| 4a6023f9f3 | |||
| 112f4a07bb | |||
| 7b3ce1ab82 | |||
| 8167f6c901 | |||
| e0779bf2d4 | |||
| d6c16cb30e | |||
| 146ef98563 | |||
| c2b0e39b06 | |||
| e556e2f16b | |||
| 4d259558c6 | |||
| b6705239a1 | |||
| 3724669ace | |||
| 4554543260 | |||
| bf43c54126 | |||
| d69ac3f084 | |||
| 1e5b8911e7 | |||
| 05dd9a4847 | |||
| 9253f00062 | |||
| 50a41ce0dc | |||
| 346e15a6e1 | |||
| a5b684efe6 | |||
| 5f190b9c50 | |||
| 011cdb0323 |
@@ -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
@@ -1,5 +1,6 @@
|
||||
DATABASE_URL="postgresql://taller:taller123@localhost:5432/taller_turnos"
|
||||
NEXTAUTH_SECRET="cambia-esto-por-un-secret-seguro"
|
||||
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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -28,6 +28,11 @@ services:
|
||||
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||
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:
|
||||
- "3004:3000"
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
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..."
|
||||
exec node_modules/.bin/next start
|
||||
|
||||
Generated
+344
-52
@@ -10,11 +10,14 @@
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"googleapis": "^173.0.0",
|
||||
"jose": "^6.2.3",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"nodemailer": "^6.10.1",
|
||||
"prisma": "^7.8.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
@@ -708,7 +711,6 @@
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
@@ -726,7 +728,6 @@
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -739,7 +740,6 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
@@ -1036,7 +1036,6 @@
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -1502,6 +1501,15 @@
|
||||
"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": {
|
||||
"version": "8.20.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -2185,7 +2202,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2195,7 +2211,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -2478,7 +2493,26 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"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"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
@@ -2496,6 +2530,15 @@
|
||||
"integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==",
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -2638,7 +2681,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -2652,7 +2694,6 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -2782,7 +2823,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -2795,7 +2835,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
@@ -2870,6 +2909,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||
@@ -2928,7 +2976,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -3058,7 +3105,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
@@ -3073,7 +3119,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
@@ -3099,7 +3144,6 @@
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/empathic": {
|
||||
@@ -3215,7 +3259,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3225,7 +3268,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3263,7 +3305,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -3812,6 +3853,12 @@
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"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": {
|
||||
"version": "3.23.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||
@@ -3916,6 +3963,29 @@
|
||||
"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": {
|
||||
"version": "6.0.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@@ -4039,7 +4121,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -4079,6 +4160,34 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
@@ -4102,7 +4211,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -4133,7 +4241,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
@@ -4188,7 +4295,6 @@
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
|
||||
"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",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
@@ -4224,7 +4330,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -4234,7 +4339,6 @@
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
@@ -4279,11 +4383,114 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -4317,6 +4524,19 @@
|
||||
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -4373,7 +4593,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -4402,7 +4621,6 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -4426,6 +4644,19 @@
|
||||
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
||||
"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": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
@@ -4717,7 +4948,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5005,7 +5235,6 @@
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
|
||||
"integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
@@ -5068,6 +5297,15 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -5312,7 +5550,6 @@
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
@@ -5334,7 +5571,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5391,7 +5627,6 @@
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -5593,6 +5828,26 @@
|
||||
"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": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||
@@ -5622,6 +5877,33 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -5665,7 +5947,6 @@
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -5911,7 +6192,6 @@
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
@@ -6463,6 +6743,21 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -6940,7 +7235,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -6960,7 +7254,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -6977,7 +7270,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
@@ -6996,7 +7288,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
@@ -7100,7 +7391,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
@@ -7119,7 +7409,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -7134,14 +7423,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -7154,7 +7441,6 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
@@ -7284,7 +7570,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7298,7 +7583,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7802,6 +8086,12 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -7941,7 +8240,6 @@
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
@@ -7960,7 +8258,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -7978,14 +8275,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -8000,7 +8295,6 @@
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -8013,7 +8307,6 @@
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -8026,7 +8319,6 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
|
||||
@@ -18,11 +18,14 @@
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"googleapis": "^173.0.0",
|
||||
"jose": "^6.2.3",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"nodemailer": "^6.10.1",
|
||||
"prisma": "^7.8.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
|
||||
@@ -26,6 +26,7 @@ model Auto {
|
||||
marca String
|
||||
modelo String
|
||||
anio Int
|
||||
nroChasis String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||
@@ -48,14 +49,30 @@ model Turno {
|
||||
clienteId String
|
||||
autoId String
|
||||
tipoServicioId String
|
||||
jefeMecanicoId String
|
||||
fechaInicio DateTime
|
||||
fechaFin DateTime
|
||||
kilometros Int
|
||||
estado EstadoTurno @default(PENDIENTE)
|
||||
comentario String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||
auto Auto @relation(fields: [autoId], 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 {
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -40,12 +40,12 @@ export default function ClientesPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<form onSubmit={buscar} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Nombre, apellido o email..."
|
||||
placeholder="Nombre, apellido o patente..."
|
||||
value={busqueda}
|
||||
onChange={(e) => setBusqueda(e.target.value)}
|
||||
className="flex-1"
|
||||
|
||||
@@ -18,6 +18,8 @@ export default function AdminDashboard() {
|
||||
const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [turnos, setTurnos] = useState<Turno[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [completando, setCompletando] = useState<Set<string>>(new Set());
|
||||
const [comentarios, setComentarios] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => { cargar(); }, [fecha]);
|
||||
|
||||
@@ -29,15 +31,26 @@ export default function AdminDashboard() {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function cambiarEstado(id: string, estado: string) {
|
||||
async function cambiarEstado(id: string, estado: string, comentario?: string) {
|
||||
await fetch(`/api/admin/turnos/${id}`, {
|
||||
method: "PATCH",
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
@@ -45,17 +58,17 @@ export default function AdminDashboard() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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
|
||||
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"
|
||||
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-400">Cargando...</div>
|
||||
<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 hay turnos para esta fecha.</p>
|
||||
@@ -96,11 +109,40 @@ export default function AdminDashboard() {
|
||||
</Button>
|
||||
</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
|
||||
</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>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
import { useState } from "react";
|
||||
import Card from "@/components/ui/Card";
|
||||
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 Auto { id: string; marca: string; modelo: string; patente: string; anio: number; }
|
||||
interface Registro {
|
||||
interface Turno {
|
||||
id: string;
|
||||
descripcion: string;
|
||||
observaciones: string | null;
|
||||
fechaRealizado: string;
|
||||
admin: { nombre: string };
|
||||
tipoServicio: { nombre: string } | null;
|
||||
fechaInicio: string;
|
||||
fechaFin: string;
|
||||
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
||||
kilometros: number;
|
||||
comentario: string | null;
|
||||
tipoServicio: { nombre: string };
|
||||
}
|
||||
interface TipoServicio { id: string; nombre: string; }
|
||||
|
||||
export default function AdminHistorialPage() {
|
||||
const [busqueda, setBusqueda] = useState("");
|
||||
@@ -22,11 +22,7 @@ export default function AdminHistorialPage() {
|
||||
const [buscando, setBuscando] = useState(false);
|
||||
const [clienteSel, setClienteSel] = useState<Cliente | null>(null);
|
||||
const [autoSel, setAutoSel] = useState<Auto | null>(null);
|
||||
const [historial, setHistorial] = useState<Registro[]>([]);
|
||||
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);
|
||||
const [turnos, setTurnos] = useState<Turno[]>([]);
|
||||
|
||||
async function buscar(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -41,50 +37,32 @@ export default function AdminHistorialPage() {
|
||||
|
||||
async function seleccionarAuto(auto: Auto) {
|
||||
setAutoSel(auto);
|
||||
const [hRes, sRes] = await Promise.all([
|
||||
fetch(`/api/admin/historial?autoId=${auto.id}`),
|
||||
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));
|
||||
const res = await fetch(`/api/admin/historial?autoId=${auto.id}`);
|
||||
setTurnos(await res.json());
|
||||
}
|
||||
|
||||
function set(f: string) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
||||
setForm((v) => ({ ...v, [f]: e.target.value }));
|
||||
}
|
||||
|
||||
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 }),
|
||||
function formatFecha(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("es-AR", {
|
||||
weekday: "short", day: "2-digit", month: "short", year: "numeric",
|
||||
});
|
||||
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 (
|
||||
<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 */}
|
||||
<Card title="Buscar cliente">
|
||||
<form onSubmit={buscar} className="flex gap-2">
|
||||
<Input
|
||||
<input
|
||||
placeholder="Nombre, apellido o email..."
|
||||
value={busqueda}
|
||||
onChange={(e) => setBusqueda(e.target.value)}
|
||||
className="flex-1"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBusqueda(e.target.value)}
|
||||
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>
|
||||
</form>
|
||||
@@ -94,7 +72,7 @@ export default function AdminHistorialPage() {
|
||||
{clientes.map((c) => (
|
||||
<button
|
||||
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"}`}
|
||||
>
|
||||
<span className="font-medium">{c.apellido}, {c.nombre}</span>
|
||||
@@ -129,79 +107,32 @@ export default function AdminHistorialPage() {
|
||||
|
||||
{/* Historial del auto */}
|
||||
{autoSel && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-700">
|
||||
{autoSel.marca} {autoSel.modelo} · <span className="font-mono">{autoSel.patente}</span>
|
||||
</h2>
|
||||
<Button onClick={() => setMostrarForm(true)} className="text-sm">+ Agregar registro</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{autoSel.marca} {autoSel.modelo} · <span className="font-mono">{autoSel.patente}</span>
|
||||
</h2>
|
||||
|
||||
{mostrarForm && (
|
||||
<Card title="Nuevo registro">
|
||||
<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>
|
||||
{turnos.length === 0 ? (
|
||||
<Card><p className="text-center text-gray-400 py-4">Sin turnos registrados para este auto.</p></Card>
|
||||
) : (
|
||||
historial.map((reg) => (
|
||||
<Card key={reg.id}>
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap mb-2">
|
||||
turnos.map((t) => (
|
||||
<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">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{reg.descripcion}</p>
|
||||
{reg.tipoServicio && (
|
||||
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">{reg.tipoServicio.nombre}</span>
|
||||
)}
|
||||
<p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
|
||||
<p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
|
||||
<p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</p>
|
||||
</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>
|
||||
<Badge estado={t.estado} />
|
||||
</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">🔢 {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>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-2">Por: {reg.admin.nombre}</p>
|
||||
</Card>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -7,6 +7,7 @@ const navItems = [
|
||||
{ href: "/admin/historial", label: "Historial", icon: "📋" },
|
||||
{ href: "/admin/clientes", label: "Clientes", icon: "👥" },
|
||||
{ href: "/admin/servicios", label: "Servicios", icon: "⚙️" },
|
||||
{ href: "/admin/jefes", label: "Jefes", icon: "🔧" },
|
||||
];
|
||||
|
||||
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}</>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 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="min-h-screen flex flex-col">
|
||||
<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">
|
||||
{navItems.map((item) => (
|
||||
<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 ${
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
: "text-gray-100 hover:bg-white/20"
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
@@ -49,6 +41,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</Link>
|
||||
</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>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function AdminLoginPage() {
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-4xl mb-2">🔧</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function ServiciosPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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
|
||||
onClick={() => { setMostrarForm(true); setEditando(null); setForm({ nombre: "", duracionHoras: "" }); }}
|
||||
className="text-sm"
|
||||
@@ -98,7 +98,7 @@ export default function ServiciosPage() {
|
||||
)}
|
||||
|
||||
{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">
|
||||
{servicios.map((s) => (
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function GET(req: NextRequest) {
|
||||
OR: [
|
||||
{ nombre: { contains: q, mode: "insensitive" } },
|
||||
{ apellido: { contains: q, mode: "insensitive" } },
|
||||
{ email: { contains: q, mode: "insensitive" } },
|
||||
{ autos: { some: { patente: { contains: q, mode: "insensitive" } } } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -10,13 +10,13 @@ export async function GET(req: NextRequest) {
|
||||
const autoId = searchParams.get("autoId");
|
||||
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
|
||||
|
||||
const historial = await prisma.historialServicio.findMany({
|
||||
const turnos = await prisma.turno.findMany({
|
||||
where: { autoId },
|
||||
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
|
||||
orderBy: { fechaRealizado: "desc" },
|
||||
include: { tipoServicio: { select: { nombre: true } } },
|
||||
orderBy: { fechaInicio: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(historial);
|
||||
return NextResponse.json(turnos);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,19 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { verificarSesion } from "@/lib/auth-admin";
|
||||
import { agregarFilaSheet } from "@/lib/google-sheets";
|
||||
|
||||
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 { estado } = await req.json();
|
||||
const { estado, comentario } = await req.json();
|
||||
|
||||
const turno = await prisma.turno.update({
|
||||
where: { id },
|
||||
data: { estado },
|
||||
data: { estado, ...(comentario !== undefined && { comentario }) },
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export async function POST(req: NextRequest) {
|
||||
const sesion = await verificarSesion("cliente_session");
|
||||
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)
|
||||
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function POST(req: NextRequest) {
|
||||
marca,
|
||||
modelo,
|
||||
anio: Number(anio),
|
||||
nroChasis: nroChasis?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ export async function GET(req: NextRequest) {
|
||||
const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } });
|
||||
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 },
|
||||
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
|
||||
orderBy: { fechaRealizado: "desc" },
|
||||
include: { tipoServicio: { select: { nombre: true } } },
|
||||
orderBy: { fechaInicio: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(historial);
|
||||
return NextResponse.json(turnos);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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) {
|
||||
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 },
|
||||
});
|
||||
|
||||
await resend.emails.send({
|
||||
from: "Taller <onboarding@resend.dev>",
|
||||
to: "jpbeldomenico@gmail.com",
|
||||
await transporter.sendMail({
|
||||
from: `"Taller" <${process.env.GMAIL_USER}>`,
|
||||
to: email,
|
||||
subject: "¡Bienvenido al taller!",
|
||||
html: `
|
||||
<h2>¡Hola ${nombre}!</h2>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -9,13 +9,16 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const tipoServicioId = searchParams.get("tipoServicioId");
|
||||
const jefeMecanicoId = searchParams.get("jefeMecanicoId");
|
||||
if (!tipoServicioId)
|
||||
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 } });
|
||||
if (!servicio || !servicio.activo)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function GET() {
|
||||
|
||||
const turnos = await prisma.turno.findMany({
|
||||
where: { clienteId: sesion.sub },
|
||||
include: { auto: true, tipoServicio: true },
|
||||
include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } },
|
||||
orderBy: { fechaInicio: "desc" },
|
||||
});
|
||||
|
||||
@@ -20,10 +20,14 @@ export async function POST(req: NextRequest) {
|
||||
const sesion = await verificarSesion("cliente_session");
|
||||
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||
|
||||
const { autoId, tipoServicioId, fechaInicio } = await req.json();
|
||||
if (!autoId || !tipoServicioId || !fechaInicio)
|
||||
const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json();
|
||||
if (!autoId || !tipoServicioId || !jefeMecanicoId || !fechaInicio || !kilometros)
|
||||
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
|
||||
const auto = await prisma.auto.findFirst({
|
||||
where: { id: autoId, clienteId: sesion.sub },
|
||||
@@ -34,16 +38,28 @@ export async function POST(req: NextRequest) {
|
||||
if (!servicio || !servicio.activo)
|
||||
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 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)
|
||||
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
|
||||
|
||||
const turno = await prisma.turno.create({
|
||||
data: { clienteId: sesion.sub, autoId, tipoServicioId, fechaInicio: inicio, fechaFin: fin },
|
||||
include: { auto: true, tipoServicio: true },
|
||||
data: {
|
||||
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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ function AuthContent() {
|
||||
<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" />
|
||||
</svg>
|
||||
<p className="text-gray-500">Verificando tu acceso...</p>
|
||||
<p className="text-gray-200">Verificando tu acceso...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,9 +39,9 @@ function AuthContent() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
|
||||
<div className="text-5xl">❌</div>
|
||||
<h1 className="text-xl font-bold text-gray-800">Link inválido o expirado</h1>
|
||||
<p className="text-gray-500">El link de acceso ya fue usado o expiró. Pedí uno nuevo.</p>
|
||||
<a href="/cliente" className="text-blue-600 hover:underline font-medium">
|
||||
<h1 className="text-xl font-bold text-white">Link inválido o expirado</h1>
|
||||
<p className="text-gray-200">El link de acceso ya fue usado o expiró. Pedí uno nuevo.</p>
|
||||
<a href="/cliente" className="text-blue-300 hover:underline font-medium">
|
||||
Volver al inicio
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Input from "@/components/ui/Input";
|
||||
import Combobox from "@/components/ui/Combobox";
|
||||
import { MARCAS, getModelos } from "@/lib/marcas-modelos";
|
||||
|
||||
interface Auto {
|
||||
id: string;
|
||||
@@ -12,6 +14,7 @@ interface Auto {
|
||||
marca: string;
|
||||
modelo: string;
|
||||
anio: number;
|
||||
nroChasis?: string | null;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -19,7 +22,8 @@ export default function DashboardPage() {
|
||||
const [autos, setAutos] = useState<Auto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 [errorForm, setErrorForm] = useState("");
|
||||
|
||||
@@ -51,7 +55,7 @@ export default function DashboardPage() {
|
||||
setGuardando(false);
|
||||
if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; }
|
||||
setMostrarForm(false);
|
||||
setForm({ patente: "", marca: "", modelo: "", anio: "" });
|
||||
setForm({ patente: "", marca: "", modelo: "", anio: "", nroChasis: "" });
|
||||
cargarAutos();
|
||||
}
|
||||
|
||||
@@ -63,19 +67,19 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Mis autos</h1>
|
||||
<button onClick={cerrarSesion} className="text-sm text-gray-400 hover:text-red-500 transition">
|
||||
<h1 className="text-2xl font-bold text-white">Mis autos</h1>
|
||||
<button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{autos.map((auto) => (
|
||||
<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>
|
||||
<p className="font-semibold text-gray-800">
|
||||
{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>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/cliente/historial?autoId=${auto.id}`}>
|
||||
<Button variant="secondary" className="text-sm">
|
||||
<Link href={`/cliente/historial?autoId=${auto.id}`} className="flex-1 sm:flex-none">
|
||||
<Button variant="secondary" className="text-sm w-full sm:w-auto">
|
||||
📋 Historial
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/cliente/turnos?autoId=${auto.id}`}>
|
||||
<Button className="text-sm">
|
||||
<Link href={`/cliente/turnos?autoId=${auto.id}`} className="flex-1 sm:flex-none">
|
||||
<Button className="text-sm w-full sm:w-auto">
|
||||
📅 Sacar turno
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -99,7 +103,7 @@ export default function DashboardPage() {
|
||||
))}
|
||||
|
||||
{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>
|
||||
)}
|
||||
@@ -108,10 +112,30 @@ export default function DashboardPage() {
|
||||
<Card title="Agregar auto">
|
||||
<form onSubmit={agregarAuto} className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input id="marca" label="Marca" placeholder="Ford" value={form.marca} onChange={set("marca")} required />
|
||||
<Input id="modelo" label="Modelo" placeholder="Focus" value={form.modelo} onChange={set("modelo")} required />
|
||||
<Combobox
|
||||
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="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>
|
||||
{errorForm && <p className="text-sm text-red-500">{errorForm}</p>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
|
||||
interface Registro {
|
||||
interface Turno {
|
||||
id: string;
|
||||
descripcion: string;
|
||||
observaciones: string | null;
|
||||
fechaRealizado: string;
|
||||
admin: { nombre: string };
|
||||
tipoServicio: { nombre: string } | null;
|
||||
fechaInicio: string;
|
||||
fechaFin: string;
|
||||
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
||||
kilometros: number;
|
||||
comentario: string | null;
|
||||
tipoServicio: { nombre: string };
|
||||
}
|
||||
|
||||
function HistorialContent() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const autoId = params.get("autoId");
|
||||
const [historial, setHistorial] = useState<Registro[]>([]);
|
||||
const [turnos, setTurnos] = useState<Turno[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -29,53 +30,61 @@ function HistorialContent() {
|
||||
return r;
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => { setHistorial(data); setLoading(false); })
|
||||
.then((data) => { setTurnos(data); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, [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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/cliente/dashboard">
|
||||
<Button variant="ghost" className="px-2">← Volver</Button>
|
||||
</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>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-400">Cargando...</div>
|
||||
) : historial.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-center text-gray-400 py-4">Este auto no tiene historial registrado todavía.</p>
|
||||
</Card>
|
||||
<div className="text-center py-10 text-gray-200">Cargando...</div>
|
||||
) : turnos.length === 0 ? (
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8 text-center">
|
||||
<p className="text-4xl mb-3">🔧</p>
|
||||
<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">
|
||||
{historial.map((reg) => (
|
||||
<Card key={reg.id}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">{reg.descripcion}</p>
|
||||
{reg.tipoServicio && (
|
||||
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">
|
||||
{reg.tipoServicio.nombre}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
{turnos.map((t) => (
|
||||
<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">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
|
||||
<p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
|
||||
<p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</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>
|
||||
<Badge estado={t.estado} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
)}
|
||||
@@ -85,7 +94,7 @@ function HistorialContent() {
|
||||
|
||||
export default function HistorialPage() {
|
||||
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 />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -6,13 +6,8 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function ClienteLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<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 className="min-h-screen">
|
||||
<main className="max-w-2xl mx-auto px-4 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,12 +33,12 @@ export default function ClienteLoginPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
|
||||
<div className="text-5xl">📧</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Revisá tu email</h1>
|
||||
<p className="text-gray-500 max-w-sm">
|
||||
<h1 className="text-2xl font-bold text-white">Revisá tu email</h1>
|
||||
<p className="text-gray-200 max-w-sm">
|
||||
Te enviamos un link de acceso a <strong>{email}</strong>. El link expira en 15 minutos.
|
||||
</p>
|
||||
<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(""); }}
|
||||
>
|
||||
Usar otro email
|
||||
|
||||
+343
-96
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Card from "@/components/ui/Card";
|
||||
@@ -7,17 +7,59 @@ import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
|
||||
interface TipoServicio { id: string; nombre: string; duracionHoras: number; }
|
||||
interface Jefe { id: string; nombre: string; }
|
||||
interface Slot { fechaInicio: string; fechaFin: string; }
|
||||
interface Turno {
|
||||
id: string;
|
||||
fechaInicio: string;
|
||||
fechaFin: string;
|
||||
id: string; fechaInicio: string; fechaFin: string;
|
||||
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
||||
tipoServicio: { nombre: 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() {
|
||||
const router = useRouter();
|
||||
@@ -27,75 +69,165 @@ function TurnosContent() {
|
||||
const [step, setStep] = useState<Step>("servicio");
|
||||
const [servicios, setServicios] = useState<TipoServicio[]>([]);
|
||||
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 [slotSel, setSlotSel] = useState<Slot | null>(null);
|
||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||
const [reservando, setReservando] = useState(false);
|
||||
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null);
|
||||
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
|
||||
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(() => {
|
||||
if (!autoId) { router.push("/cliente/dashboard"); return; }
|
||||
fetch("/api/admin/servicios")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setServicios(data.filter((s: TipoServicio & { activo: boolean }) => s.activo)));
|
||||
fetch("/api/cliente/servicios").then(r => r.json()).then(setServicios);
|
||||
fetch("/api/cliente/turnos")
|
||||
.then((r) => r.json())
|
||||
.then((data) => { setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId)); setLoadingTurnos(false); });
|
||||
.then(r => r.json())
|
||||
.then((data) => {
|
||||
setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId));
|
||||
setLoadingTurnos(false);
|
||||
});
|
||||
}, [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) {
|
||||
setServicioSel(s);
|
||||
setLoadingSlots(true);
|
||||
setStep("slot");
|
||||
const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}`);
|
||||
setJefeSel(null);
|
||||
setSlots([]);
|
||||
setDiaSeleccionado(null);
|
||||
setSlotSel(null);
|
||||
setLoadingJefes(true);
|
||||
setStep("jefe");
|
||||
const res = await fetch("/api/cliente/jefes");
|
||||
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);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!slotSel || !servicioSel || !autoId) return;
|
||||
if (!slotSel || !servicioSel || !jefeSel || !autoId || !kilometros || kmError) return;
|
||||
setReservando(true);
|
||||
setReservaError("");
|
||||
const res = await fetch("/api/cliente/turnos", {
|
||||
method: "POST",
|
||||
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();
|
||||
setReservando(false);
|
||||
if (res.ok) {
|
||||
setTurnoCreado(data);
|
||||
setDiaSeleccionado(null);
|
||||
setStep("confirmado");
|
||||
} else {
|
||||
setReservaError(data?.error || "Ocurrió un error al reservar. Intentá de nuevo.");
|
||||
}
|
||||
}
|
||||
|
||||
function formatSlot(slot: Slot) {
|
||||
const inicio = new Date(slot.fechaInicio);
|
||||
const fin = new Date(slot.fechaFin);
|
||||
const dia = inicio.toLocaleDateString("es-AR", { weekday: "long", day: "numeric", month: "long" });
|
||||
const h1 = inicio.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
||||
const h2 = fin.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
||||
return { dia: dia.charAt(0).toUpperCase() + dia.slice(1), horas: `${h1} - ${h2}` };
|
||||
// Navegación de mes
|
||||
const hoyStr = hoyArg();
|
||||
const mesActual = { year: hoy.getFullYear(), month: hoy.getMonth() };
|
||||
const puedeRetroceder = mesVista.year > mesActual.year || (mesVista.year === mesActual.year && mesVista.month > mesActual.month);
|
||||
|
||||
function prevMes() {
|
||||
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) {
|
||||
const { dia, horas } = formatSlot({ fechaInicio: turnoCreado.fechaInicio, fechaFin: turnoCreado.fechaFin });
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center gap-4">
|
||||
<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">
|
||||
<p className="text-sm text-gray-500 mb-1">Servicio</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="font-semibold mb-1">{dia}</p>
|
||||
<p className="text-gray-600">{horas}</p>
|
||||
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
|
||||
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>
|
||||
<Link href="/cliente/dashboard">
|
||||
<Button variant="secondary">← Volver a mis autos</Button>
|
||||
</Link>
|
||||
<Link href="/cliente/dashboard"><Button variant="secondary">← Volver a mis autos</Button></Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,30 +235,30 @@ function TurnosContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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>
|
||||
) : (
|
||||
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{step === "servicio" ? "Elegí el servicio" : "Elegí el horario"}
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-white">
|
||||
{step === "servicio" && "Elegí el servicio"}
|
||||
{step === "jefe" && "Elegí el jefe mecánico"}
|
||||
{step === "calendario" && `${servicioSel?.nombre}${jefeSel ? ` · ${jefeSel.nombre}` : ""}`}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* STEP: servicio */}
|
||||
{step === "servicio" && (
|
||||
<>
|
||||
<div className="grid gap-3">
|
||||
{servicios.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
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"
|
||||
>
|
||||
{servicios.map(s => (
|
||||
<button key={s.id} 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>
|
||||
<p className="font-medium text-gray-800">{s.nombre}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Duración: {s.duracionHoras === 1 ? "1 hora" : `${s.duracionHoras} horas`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
|
||||
</div>
|
||||
<span className="text-gray-400 text-lg">→</span>
|
||||
</button>
|
||||
@@ -136,78 +268,193 @@ function TurnosContent() {
|
||||
{!loadingTurnos && misTurnos.length > 0 && (
|
||||
<Card title="Mis turnos para este auto">
|
||||
<div className="flex flex-col gap-3">
|
||||
{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>
|
||||
<p className="text-sm font-medium">{t.tipoServicio.nombre}</p>
|
||||
<p className="text-xs text-gray-500">{dia} · {horas}</p>
|
||||
</div>
|
||||
<Badge estado={t.estado} />
|
||||
{misTurnos.map(t => (
|
||||
<div key={t.id} className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.tipoServicio.nombre}</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>
|
||||
);
|
||||
})}
|
||||
<Badge estado={t.estado} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "slot" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{loadingSlots ? (
|
||||
<div className="text-center py-10 text-gray-400">Buscando horarios disponibles...</div>
|
||||
) : slots.length === 0 ? (
|
||||
{/* STEP: jefe */}
|
||||
{step === "jefe" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{loadingJefes ? (
|
||||
<p className="text-center text-sm text-gray-200">Cargando jefes...</p>
|
||||
) : jefes.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-center text-gray-400 py-4">
|
||||
No hay horarios disponibles en los próximos 14 días. Consultá al taller.
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-500">
|
||||
Servicio: <strong>{servicioSel?.nombre}</strong>
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{slots.map((slot, i) => {
|
||||
const { dia, horas } = formatSlot(slot);
|
||||
const sel = slotSel?.fechaInicio === slot.fechaInicio;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSlotSel(slot)}
|
||||
className={`text-left rounded-xl p-4 border transition ${
|
||||
sel
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 bg-white hover:border-blue-300"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-gray-800">{dia}</p>
|
||||
<p className="text-sm text-gray-500">{horas}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
onClick={reservar}
|
||||
disabled={!slotSel}
|
||||
loading={reservando}
|
||||
className="w-full mt-2"
|
||||
jefes.map((j) => (
|
||||
<button
|
||||
key={j.id}
|
||||
onClick={() => seleccionarJefe(j)}
|
||||
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"
|
||||
>
|
||||
Confirmar turno
|
||||
</Button>
|
||||
</>
|
||||
<div>
|
||||
<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 (
|
||||
<button
|
||||
key={dateStr}
|
||||
disabled={deshabilitado}
|
||||
onClick={() => seleccionarDia(dateStr)}
|
||||
className={`aspect-square flex items-center justify-center text-sm relative transition
|
||||
${deshabilitado ? "cursor-default" : "hover:bg-blue-50 cursor-pointer"}
|
||||
${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" : ""}
|
||||
`}
|
||||
>
|
||||
{dia}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loadingSlots && slots.length === 0 && (
|
||||
<p className="text-center text-sm text-gray-200">No hay turnos disponibles en los próximos 60 días.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{reservaError && <p className="text-xs text-red-500 mt-2 text-center">{reservaError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TurnosPage() {
|
||||
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 />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
+8
-9
@@ -3,20 +3,19 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -14,8 +14,8 @@ const geistMono = localFont({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Taller MG",
|
||||
description: "Sistema de turnos - Taller MG",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,14 @@ export async function crearSesionCliente(clienteId: string): Promise<string> {
|
||||
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(
|
||||
cookieName: string
|
||||
): Promise<{ sub: string; role: string } | null> {
|
||||
|
||||
@@ -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
@@ -1,21 +1,28 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { Resend } from "resend";
|
||||
import nodemailer from "nodemailer";
|
||||
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 }> {
|
||||
const cliente = await prisma.cliente.findUnique({ where: { email } });
|
||||
if (!cliente) return { ok: false, error: "Email no registrado" };
|
||||
|
||||
// Invalidar tokens anteriores no usados
|
||||
await prisma.magicToken.updateMany({
|
||||
where: { clienteId: cliente.id, usado: false },
|
||||
data: { usado: true },
|
||||
});
|
||||
|
||||
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({
|
||||
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}`;
|
||||
|
||||
await resend.emails.send({
|
||||
from: "Taller <onboarding@resend.dev>",
|
||||
to: "jpbeldomenico@gmail.com",
|
||||
await transporter.sendMail({
|
||||
from: `"Taller" <${process.env.GMAIL_USER}>`,
|
||||
to: email,
|
||||
subject: "Tu link de acceso al taller",
|
||||
html: `
|
||||
<h2>Hola ${cliente.nombre}!</h2>
|
||||
|
||||
@@ -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
@@ -1,92 +1,143 @@
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
const SLOTS_PARALELOS = 4; // 2 jefes × 2 autos c/u
|
||||
const HORA_INICIO = 8;
|
||||
const HORA_FIN = 18;
|
||||
const INICIO_H = 8;
|
||||
const INICIO_M = 30;
|
||||
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 {
|
||||
const dia = fecha.getDay(); // 0=Dom, 6=Sab
|
||||
const dia = diaArgentina(fecha);
|
||||
return dia >= 1 && dia <= 5;
|
||||
}
|
||||
|
||||
function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
|
||||
const h1 = inicio.getHours() + inicio.getMinutes() / 60;
|
||||
const h2 = fin.getHours() + fin.getMinutes() / 60;
|
||||
return h1 >= HORA_INICIO && h2 <= HORA_FIN;
|
||||
const iMin = minutosArgentina(inicio);
|
||||
const fMin = minutosArgentina(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(inicio: Date, fin: Date): Promise<number> {
|
||||
async function turnosSolapados(
|
||||
inicio: Date,
|
||||
fin: Date,
|
||||
jefeMecanicoId: string
|
||||
): Promise<number> {
|
||||
const count = await prisma.turno.count({
|
||||
where: {
|
||||
jefeMecanicoId,
|
||||
estado: { in: ["PENDIENTE", "CONFIRMADO"] },
|
||||
AND: [
|
||||
{ fechaInicio: { lt: fin } },
|
||||
{ fechaFin: { gt: inicio } },
|
||||
],
|
||||
AND: [{ fechaInicio: { lt: fin } }, { fechaFin: { gt: inicio } }],
|
||||
},
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
async function obtenerJefeActivo(jefeMecanicoId: string) {
|
||||
return prisma.jefeMecanico.findUnique({
|
||||
where: { id: jefeMecanicoId },
|
||||
select: { slotsParalelos: true, activo: true },
|
||||
});
|
||||
}
|
||||
|
||||
export interface SlotDisponible {
|
||||
fechaInicio: Date;
|
||||
fechaFin: Date;
|
||||
}
|
||||
|
||||
// Busca los próximos N slots disponibles para una duración dada (en horas)
|
||||
export async function obtenerSlotsDisponibles(
|
||||
jefeMecanicoId: string,
|
||||
duracionHoras: number,
|
||||
diasAdelante = 14
|
||||
diasAdelante = 14,
|
||||
limite = 300
|
||||
): Promise<SlotDisponible[]> {
|
||||
const jefe = await obtenerJefeActivo(jefeMecanicoId);
|
||||
if (!jefe || !jefe.activo) return [];
|
||||
const cap = jefe.slotsParalelos;
|
||||
|
||||
const slots: SlotDisponible[] = [];
|
||||
const ahora = new Date();
|
||||
const duracionMs = duracionHoras * 60 * 60 * 1000;
|
||||
|
||||
const fechaLimite = new Date(ahora);
|
||||
fechaLimite.setDate(fechaLimite.getDate() + diasAdelante);
|
||||
const fechaLimite = new Date(ahora.getTime() + diasAdelante * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Avanzar al próximo slot (:00 o :30 Argentina) a partir de ahora
|
||||
let cursor = new Date(ahora);
|
||||
// Empezar desde el siguiente slot redondo (media hora)
|
||||
cursor.setMinutes(cursor.getMinutes() >= 30 ? 60 : 30, 0, 0);
|
||||
const argMNow = minutosArgentina(cursor) % 60;
|
||||
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)) {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
cursor.setHours(HORA_INICIO, 0, 0, 0);
|
||||
cursor = inicioJornada(new Date(cursor.getTime() + 24 * 60 * 60 * 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
const hInicio = cursor.getHours() + cursor.getMinutes() / 60;
|
||||
if (hInicio < HORA_INICIO) {
|
||||
cursor.setHours(HORA_INICIO, 0, 0, 0);
|
||||
const minActual = minutosArgentina(cursor);
|
||||
if (minActual < INICIO_MINUTOS) {
|
||||
cursor = inicioJornada(cursor);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fin = new Date(cursor.getTime() + duracionMs);
|
||||
const hFin = fin.getHours() + fin.getMinutes() / 60;
|
||||
|
||||
if (hFin > HORA_FIN) {
|
||||
// Pasar al día siguiente
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
cursor.setHours(HORA_INICIO, 0, 0, 0);
|
||||
if (!horasDentroDeJornada(cursor, fin)) {
|
||||
cursor = inicioJornada(new Date(cursor.getTime() + 24 * 60 * 60 * 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
const ocupados = await turnosSolapados(cursor, fin);
|
||||
if (ocupados < SLOTS_PARALELOS) {
|
||||
const ocupados = await turnosSolapados(cursor, fin, jefeMecanicoId);
|
||||
if (ocupados < cap) {
|
||||
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
|
||||
}
|
||||
|
||||
// Avanzar 30 minutos
|
||||
cursor = new Date(cursor.getTime() + 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
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;
|
||||
const ocupados = await turnosSolapados(inicio, fin);
|
||||
return ocupados < SLOTS_PARALELOS;
|
||||
const jefe = await obtenerJefeActivo(jefeMecanicoId);
|
||||
if (!jefe || !jefe.activo) return false;
|
||||
const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId);
|
||||
return ocupados < jefe.slotsParalelos;
|
||||
}
|
||||
|
||||
+7
-1
@@ -28,14 +28,20 @@ export async function middleware(req: NextRequest) {
|
||||
pathname.startsWith("/admin/turnos") ||
|
||||
pathname.startsWith("/admin/historial") ||
|
||||
pathname.startsWith("/admin/servicios") ||
|
||||
pathname.startsWith("/admin/jefes") ||
|
||||
pathname.startsWith("/admin/clientes")) {
|
||||
const ok = await verifyCookie(req, "admin_session");
|
||||
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();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/cliente/:path*", "/admin/:path*"],
|
||||
matcher: ["/cliente/:path*", "/admin/:path*", "/jefe/:path*"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user