Compare commits
33 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 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -6,11 +6,37 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: self-hosted
|
runs-on: taller
|
||||||
steps:
|
steps:
|
||||||
- name: Pull y rebuild
|
- name: Pull y rebuild
|
||||||
run: |
|
run: |
|
||||||
|
set -eu
|
||||||
|
git config --global --add safe.directory /home/proyecto/taller_automotriz
|
||||||
cd /home/proyecto/taller_automotriz
|
cd /home/proyecto/taller_automotriz
|
||||||
git pull
|
git fetch origin
|
||||||
docker compose down
|
git reset --hard origin/main
|
||||||
docker compose up -d --build
|
|
||||||
|
# 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}
|
NEXTAUTH_URL: ${NEXTAUTH_URL}
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY}
|
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||||
MAGIC_LINK_BASE_URL: ${MAGIC_LINK_BASE_URL}
|
MAGIC_LINK_BASE_URL: ${MAGIC_LINK_BASE_URL}
|
||||||
|
GMAIL_USER: ${GMAIL_USER}
|
||||||
|
GMAIL_APP_PASSWORD: ${GMAIL_APP_PASSWORD}
|
||||||
|
GOOGLE_CLIENT_EMAIL: ${GOOGLE_CLIENT_EMAIL}
|
||||||
|
GOOGLE_SHEET_ID: ${GOOGLE_SHEET_ID}
|
||||||
|
GOOGLE_PRIVATE_KEY: ${GOOGLE_PRIVATE_KEY}
|
||||||
ports:
|
ports:
|
||||||
- "3004:3000"
|
- "3004:3000"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
echo "Corriendo migraciones..."
|
echo "Corriendo migraciones..."
|
||||||
node node_modules/prisma/build/index.js migrate deploy
|
node node_modules/prisma/build/index.js db push --accept-data-loss
|
||||||
echo "Migraciones OK. Iniciando app..."
|
echo "Migraciones OK. Iniciando app..."
|
||||||
exec node_modules/.bin/next start
|
exec node_modules/.bin/next start
|
||||||
|
|||||||
Generated
+375
-53
@@ -12,6 +12,7 @@
|
|||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"googleapis": "^173.0.0",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18"
|
"react-dom": "^18",
|
||||||
|
"resend": "^6.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
@@ -709,7 +711,6 @@
|
|||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^5.1.2",
|
"string-width": "^5.1.2",
|
||||||
@@ -727,7 +728,6 @@
|
|||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -740,7 +740,6 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^6.2.2"
|
"ansi-regex": "^6.2.2"
|
||||||
@@ -1037,7 +1036,6 @@
|
|||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1417,6 +1415,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2168,6 +2172,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -2189,7 +2202,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -2199,7 +2211,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -2482,7 +2493,26 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcryptjs": {
|
||||||
@@ -2500,6 +2530,15 @@
|
|||||||
"integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==",
|
"integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bignumber.js": {
|
||||||
|
"version": "9.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||||
|
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -2642,7 +2681,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -2656,7 +2694,6 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -2786,7 +2823,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -2799,7 +2835,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
@@ -2874,6 +2909,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/data-uri-to-buffer": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||||
@@ -2932,7 +2976,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -3062,7 +3105,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@@ -3077,7 +3119,6 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ecdsa-sig-formatter": {
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
@@ -3103,7 +3144,6 @@
|
|||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/empathic": {
|
"node_modules/empathic": {
|
||||||
@@ -3219,7 +3259,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3229,7 +3268,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3267,7 +3305,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -3816,6 +3853,12 @@
|
|||||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-check": {
|
"node_modules/fast-check": {
|
||||||
"version": "3.23.2",
|
"version": "3.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
@@ -3888,6 +3931,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||||
@@ -3914,6 +3963,29 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-blob": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-domexception": "^1.0.0",
|
||||||
|
"web-streams-polyfill": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20 || >= 14.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -4011,6 +4083,18 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/formdata-polyfill": {
|
||||||
|
"version": "4.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
|
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-blob": "^3.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs.realpath": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
@@ -4037,7 +4121,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -4077,6 +4160,34 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gaxios": {
|
||||||
|
"version": "7.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz",
|
||||||
|
"integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.1",
|
||||||
|
"node-fetch": "^3.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gcp-metadata": {
|
||||||
|
"version": "8.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||||
|
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"gaxios": "^7.0.0",
|
||||||
|
"google-logging-utils": "^1.0.0",
|
||||||
|
"json-bigint": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/generate-function": {
|
"node_modules/generate-function": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
@@ -4100,7 +4211,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -4131,7 +4241,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@@ -4186,7 +4295,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
|
||||||
"integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
|
"integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
|
||||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"foreground-child": "^3.1.0",
|
"foreground-child": "^3.1.0",
|
||||||
@@ -4222,7 +4330,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -4232,7 +4339,6 @@
|
|||||||
"version": "9.0.9",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.2"
|
"brace-expansion": "^2.0.2"
|
||||||
@@ -4277,11 +4383,114 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/google-auth-library": {
|
||||||
|
"version": "10.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz",
|
||||||
|
"integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"ecdsa-sig-formatter": "^1.0.11",
|
||||||
|
"gaxios": "^7.1.4",
|
||||||
|
"gcp-metadata": "8.1.2",
|
||||||
|
"google-logging-utils": "1.1.3",
|
||||||
|
"jws": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-logging-utils": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/googleapis": {
|
||||||
|
"version": "173.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-173.0.0.tgz",
|
||||||
|
"integrity": "sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"google-auth-library": "^10.2.0",
|
||||||
|
"googleapis-common": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/googleapis-common": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-5MXeQzIZaqCH7B+HJWqhQm946VARpZep6acbWSr/fcgF2cQANq7allgX+i/G0EqF0WyUxB277gtWMzRYHMl9tg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"gaxios": "7.1.3",
|
||||||
|
"google-auth-library": "10.5.0",
|
||||||
|
"google-logging-utils": "1.1.3",
|
||||||
|
"qs": "^6.7.0",
|
||||||
|
"url-template": "^2.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/googleapis-common/node_modules/gaxios": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.1",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"rimraf": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/googleapis-common/node_modules/google-auth-library": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"ecdsa-sig-formatter": "^1.0.11",
|
||||||
|
"gaxios": "^7.0.0",
|
||||||
|
"gcp-metadata": "^8.0.0",
|
||||||
|
"google-logging-utils": "^1.0.0",
|
||||||
|
"gtoken": "^8.0.0",
|
||||||
|
"jws": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/googleapis-common/node_modules/rimraf": {
|
||||||
|
"version": "5.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
|
||||||
|
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^10.3.7"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rimraf": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4315,6 +4524,19 @@
|
|||||||
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/gtoken": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"gaxios": "^7.0.0",
|
||||||
|
"jws": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-bigints": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -4371,7 +4593,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4400,7 +4621,6 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -4424,6 +4644,19 @@
|
|||||||
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -4715,7 +4948,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5003,7 +5235,6 @@
|
|||||||
"version": "2.3.6",
|
"version": "2.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
|
||||||
"integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
|
"integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/cliui": "^8.0.2"
|
"@isaacs/cliui": "^8.0.2"
|
||||||
@@ -5066,6 +5297,15 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-bigint": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bignumber.js": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-buffer": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
@@ -5310,7 +5550,6 @@
|
|||||||
"version": "10.4.3",
|
"version": "10.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/lru.min": {
|
"node_modules/lru.min": {
|
||||||
@@ -5332,7 +5571,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5389,7 +5627,6 @@
|
|||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -5591,6 +5828,26 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-domexception": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||||
|
"deprecated": "Use your platform's native DOMException instead",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-exports-info": {
|
"node_modules/node-exports-info": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||||
@@ -5620,6 +5877,24 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
|
"fetch-blob": "^3.1.4",
|
||||||
|
"formdata-polyfill": "^4.0.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/node-fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "6.10.1",
|
"version": "6.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||||
@@ -5672,7 +5947,6 @@
|
|||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5918,7 +6192,6 @@
|
|||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lru-cache": "^10.2.0",
|
"lru-cache": "^10.2.0",
|
||||||
@@ -6101,6 +6374,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postal-mime": {
|
||||||
|
"version": "2.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
|
||||||
|
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
@@ -6464,6 +6743,21 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -6612,6 +6906,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.14.0.tgz",
|
||||||
|
"integrity": "sha512-jVdpUgOoWGLjaP64lo8KwzHT9gY4w6Dl8c36CIb2F+ayYOMLr3khqs8xrNjXM2k19b+lPoj0VWQFhVNLiToBjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postal-mime": "2.7.4",
|
||||||
|
"standardwebhooks": "1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
@@ -6920,7 +7235,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -6940,7 +7254,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -6957,7 +7270,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -6976,7 +7288,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -7038,6 +7349,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
@@ -7070,7 +7391,6 @@
|
|||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eastasianwidth": "^0.2.0",
|
"eastasianwidth": "^0.2.0",
|
||||||
@@ -7089,7 +7409,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -7104,14 +7423,12 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/string-width/node_modules/ansi-regex": {
|
"node_modules/string-width/node_modules/ansi-regex": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -7124,7 +7441,6 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^6.2.2"
|
"ansi-regex": "^6.2.2"
|
||||||
@@ -7254,7 +7570,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -7268,7 +7583,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -7772,6 +8086,12 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/url-template": {
|
||||||
|
"version": "2.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
||||||
|
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
|
||||||
|
"license": "BSD"
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -7793,6 +8113,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-streams-polyfill": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7911,7 +8240,6 @@
|
|||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^6.1.0",
|
"ansi-styles": "^6.1.0",
|
||||||
@@ -7930,7 +8258,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@@ -7948,14 +8275,12 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -7970,7 +8295,6 @@
|
|||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -7983,7 +8307,6 @@
|
|||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -7996,7 +8319,6 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^6.2.2"
|
"ansi-regex": "^6.2.2"
|
||||||
|
|||||||
+3
-1
@@ -20,6 +20,7 @@
|
|||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"googleapis": "^173.0.0",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
@@ -27,7 +28,8 @@
|
|||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18"
|
"react-dom": "^18",
|
||||||
|
"resend": "^6.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ model Auto {
|
|||||||
marca String
|
marca String
|
||||||
modelo String
|
modelo String
|
||||||
anio Int
|
anio Int
|
||||||
|
nroChasis String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
cliente Cliente @relation(fields: [clienteId], references: [id])
|
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||||
@@ -48,14 +49,30 @@ model Turno {
|
|||||||
clienteId String
|
clienteId String
|
||||||
autoId String
|
autoId String
|
||||||
tipoServicioId String
|
tipoServicioId String
|
||||||
|
jefeMecanicoId String
|
||||||
fechaInicio DateTime
|
fechaInicio DateTime
|
||||||
fechaFin DateTime
|
fechaFin DateTime
|
||||||
|
kilometros Int
|
||||||
estado EstadoTurno @default(PENDIENTE)
|
estado EstadoTurno @default(PENDIENTE)
|
||||||
|
comentario String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
cliente Cliente @relation(fields: [clienteId], references: [id])
|
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||||
auto Auto @relation(fields: [autoId], references: [id])
|
auto Auto @relation(fields: [autoId], references: [id])
|
||||||
tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id])
|
tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id])
|
||||||
|
jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model JefeMecanico {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
slotsParalelos Int @default(1)
|
||||||
|
activo Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
turnos Turno[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model HistorialServicio {
|
model HistorialServicio {
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -40,12 +40,12 @@ export default function ClientesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Clientes</h1>
|
<h1 className="text-2xl font-bold text-white">Clientes</h1>
|
||||||
|
|
||||||
<Card title="Buscar cliente">
|
<Card title="Buscar cliente">
|
||||||
<form onSubmit={buscar} className="flex gap-2">
|
<form onSubmit={buscar} className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Nombre, apellido o email..."
|
placeholder="Nombre, apellido o patente..."
|
||||||
value={busqueda}
|
value={busqueda}
|
||||||
onChange={(e) => setBusqueda(e.target.value)}
|
onChange={(e) => setBusqueda(e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export default function AdminDashboard() {
|
|||||||
const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10));
|
const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [turnos, setTurnos] = useState<Turno[]>([]);
|
const [turnos, setTurnos] = useState<Turno[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [completando, setCompletando] = useState<Set<string>>(new Set());
|
||||||
|
const [comentarios, setComentarios] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => { cargar(); }, [fecha]);
|
useEffect(() => { cargar(); }, [fecha]);
|
||||||
|
|
||||||
@@ -29,15 +31,26 @@ export default function AdminDashboard() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cambiarEstado(id: string, estado: string) {
|
async function cambiarEstado(id: string, estado: string, comentario?: string) {
|
||||||
await fetch(`/api/admin/turnos/${id}`, {
|
await fetch(`/api/admin/turnos/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ estado }),
|
body: JSON.stringify({ estado, comentario }),
|
||||||
});
|
});
|
||||||
|
setCompletando((prev) => { const s = new Set(prev); s.delete(id); return s; });
|
||||||
|
setComentarios((prev) => { const c = { ...prev }; delete c[id]; return c; });
|
||||||
cargar();
|
cargar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function iniciarCompletado(id: string) {
|
||||||
|
setCompletando((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelarCompletado(id: string) {
|
||||||
|
setCompletando((prev) => { const s = new Set(prev); s.delete(id); return s; });
|
||||||
|
setComentarios((prev) => { const c = { ...prev }; delete c[id]; return c; });
|
||||||
|
}
|
||||||
|
|
||||||
function formatHora(iso: string) {
|
function formatHora(iso: string) {
|
||||||
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
@@ -45,17 +58,17 @@ export default function AdminDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Turnos del día</h1>
|
<h1 className="text-xl sm:text-2xl font-bold text-white">Turnos del día</h1>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={fecha}
|
value={fecha}
|
||||||
onChange={(e) => setFecha(e.target.value)}
|
onChange={(e) => setFecha(e.target.value)}
|
||||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 w-full sm:w-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-gray-400">Cargando...</div>
|
<div className="text-center py-10 text-gray-200">Cargando...</div>
|
||||||
) : turnos.length === 0 ? (
|
) : turnos.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-center text-gray-400 py-4">No hay turnos para esta fecha.</p>
|
<p className="text-center text-gray-400 py-4">No hay turnos para esta fecha.</p>
|
||||||
@@ -96,11 +109,40 @@ export default function AdminDashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{t.estado === "CONFIRMADO" && (
|
|
||||||
<Button onClick={() => cambiarEstado(t.id, "COMPLETADO")} variant="secondary" className="text-sm">
|
{t.estado === "CONFIRMADO" && !completando.has(t.id) && (
|
||||||
|
<Button onClick={() => iniciarCompletado(t.id)} variant="secondary" className="text-sm">
|
||||||
✓ Marcar completado
|
✓ Marcar completado
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{t.estado === "CONFIRMADO" && completando.has(t.id) && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
value={comentarios[t.id] ?? ""}
|
||||||
|
onChange={(e) => setComentarios((prev) => ({ ...prev, [t.id]: e.target.value }))}
|
||||||
|
placeholder="Observaciones (opcional)..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => cambiarEstado(t.id, "COMPLETADO", comentarios[t.id])}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1 text-sm"
|
||||||
|
>
|
||||||
|
✓ Confirmar completado
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => cancelarCompletado(t.id)}
|
||||||
|
className="flex-1 text-sm"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Card from "@/components/ui/Card";
|
import Card from "@/components/ui/Card";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
import Input from "@/components/ui/Input";
|
import Badge from "@/components/ui/Badge";
|
||||||
|
|
||||||
interface Cliente { id: string; nombre: string; apellido: string; email: string; autos: Auto[]; }
|
interface Cliente { id: string; nombre: string; apellido: string; email: string; autos: Auto[]; }
|
||||||
interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; }
|
interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; }
|
||||||
interface Registro {
|
interface Turno {
|
||||||
id: string;
|
id: string;
|
||||||
descripcion: string;
|
fechaInicio: string;
|
||||||
observaciones: string | null;
|
fechaFin: string;
|
||||||
fechaRealizado: string;
|
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
||||||
admin: { nombre: string };
|
kilometros: number;
|
||||||
tipoServicio: { nombre: string } | null;
|
comentario: string | null;
|
||||||
|
tipoServicio: { nombre: string };
|
||||||
}
|
}
|
||||||
interface TipoServicio { id: string; nombre: string; }
|
|
||||||
|
|
||||||
export default function AdminHistorialPage() {
|
export default function AdminHistorialPage() {
|
||||||
const [busqueda, setBusqueda] = useState("");
|
const [busqueda, setBusqueda] = useState("");
|
||||||
@@ -22,11 +22,7 @@ export default function AdminHistorialPage() {
|
|||||||
const [buscando, setBuscando] = useState(false);
|
const [buscando, setBuscando] = useState(false);
|
||||||
const [clienteSel, setClienteSel] = useState<Cliente | null>(null);
|
const [clienteSel, setClienteSel] = useState<Cliente | null>(null);
|
||||||
const [autoSel, setAutoSel] = useState<Auto | null>(null);
|
const [autoSel, setAutoSel] = useState<Auto | null>(null);
|
||||||
const [historial, setHistorial] = useState<Registro[]>([]);
|
const [turnos, setTurnos] = useState<Turno[]>([]);
|
||||||
const [servicios, setServicios] = useState<TipoServicio[]>([]);
|
|
||||||
const [mostrarForm, setMostrarForm] = useState(false);
|
|
||||||
const [form, setForm] = useState({ descripcion: "", observaciones: "", tipoServicioId: "", fechaRealizado: new Date().toISOString().slice(0, 10) });
|
|
||||||
const [guardando, setGuardando] = useState(false);
|
|
||||||
|
|
||||||
async function buscar(e: React.FormEvent) {
|
async function buscar(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -41,50 +37,32 @@ export default function AdminHistorialPage() {
|
|||||||
|
|
||||||
async function seleccionarAuto(auto: Auto) {
|
async function seleccionarAuto(auto: Auto) {
|
||||||
setAutoSel(auto);
|
setAutoSel(auto);
|
||||||
const [hRes, sRes] = await Promise.all([
|
const res = await fetch(`/api/admin/historial?autoId=${auto.id}`);
|
||||||
fetch(`/api/admin/historial?autoId=${auto.id}`),
|
setTurnos(await res.json());
|
||||||
fetch(`/api/admin/servicios`),
|
|
||||||
]);
|
|
||||||
const [h, s] = await Promise.all([hRes.json(), sRes.json()]);
|
|
||||||
setHistorial(h);
|
|
||||||
setServicios(s.filter((sv: TipoServicio & { activo: boolean }) => sv.activo));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function set(f: string) {
|
function formatFecha(iso: string) {
|
||||||
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
return new Date(iso).toLocaleDateString("es-AR", {
|
||||||
setForm((v) => ({ ...v, [f]: e.target.value }));
|
weekday: "short", day: "2-digit", month: "short", year: "numeric",
|
||||||
}
|
|
||||||
|
|
||||||
async function guardarRegistro(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!autoSel) return;
|
|
||||||
setGuardando(true);
|
|
||||||
const res = await fetch("/api/admin/historial", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ autoId: autoSel.id, ...form, tipoServicioId: form.tipoServicioId || null }),
|
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
}
|
||||||
const nuevo = await res.json();
|
|
||||||
setHistorial((h) => [nuevo, ...h]);
|
function formatHora(iso: string) {
|
||||||
setMostrarForm(false);
|
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
||||||
setForm({ descripcion: "", observaciones: "", tipoServicioId: "", fechaRealizado: new Date().toISOString().slice(0, 10) });
|
|
||||||
}
|
|
||||||
setGuardando(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Historial de autos</h1>
|
<h1 className="text-2xl font-bold text-white">Historial de autos</h1>
|
||||||
|
|
||||||
{/* Buscar cliente */}
|
{/* Buscar cliente */}
|
||||||
<Card title="Buscar cliente">
|
<Card title="Buscar cliente">
|
||||||
<form onSubmit={buscar} className="flex gap-2">
|
<form onSubmit={buscar} className="flex gap-2">
|
||||||
<Input
|
<input
|
||||||
placeholder="Nombre, apellido o email..."
|
placeholder="Nombre, apellido o email..."
|
||||||
value={busqueda}
|
value={busqueda}
|
||||||
onChange={(e) => setBusqueda(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBusqueda(e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" loading={buscando}>Buscar</Button>
|
<Button type="submit" loading={buscando}>Buscar</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -94,7 +72,7 @@ export default function AdminHistorialPage() {
|
|||||||
{clientes.map((c) => (
|
{clientes.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => { setClienteSel(c); setAutoSel(null); setHistorial([]); }}
|
onClick={() => { setClienteSel(c); setAutoSel(null); setTurnos([]); }}
|
||||||
className={`text-left px-3 py-2 rounded-lg border transition text-sm ${clienteSel?.id === c.id ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-blue-300"}`}
|
className={`text-left px-3 py-2 rounded-lg border transition text-sm ${clienteSel?.id === c.id ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-blue-300"}`}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{c.apellido}, {c.nombre}</span>
|
<span className="font-medium">{c.apellido}, {c.nombre}</span>
|
||||||
@@ -129,79 +107,32 @@ export default function AdminHistorialPage() {
|
|||||||
|
|
||||||
{/* Historial del auto */}
|
{/* Historial del auto */}
|
||||||
{autoSel && (
|
{autoSel && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<h2 className="text-lg font-semibold text-white">
|
||||||
<h2 className="text-lg font-semibold text-gray-700">
|
{autoSel.marca} {autoSel.modelo} · <span className="font-mono">{autoSel.patente}</span>
|
||||||
{autoSel.marca} {autoSel.modelo} · <span className="font-mono">{autoSel.patente}</span>
|
</h2>
|
||||||
</h2>
|
|
||||||
<Button onClick={() => setMostrarForm(true)} className="text-sm">+ Agregar registro</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mostrarForm && (
|
{turnos.length === 0 ? (
|
||||||
<Card title="Nuevo registro">
|
<Card><p className="text-center text-gray-400 py-4">Sin turnos registrados para este auto.</p></Card>
|
||||||
<form onSubmit={guardarRegistro} className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-sm font-medium text-gray-700">Servicio (opcional)</label>
|
|
||||||
<select
|
|
||||||
value={form.tipoServicioId}
|
|
||||||
onChange={set("tipoServicioId")}
|
|
||||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">Sin categoría</option>
|
|
||||||
{servicios.map((s) => <option key={s.id} value={s.id}>{s.nombre}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Input id="fechaRealizado" type="date" label="Fecha realizado" value={form.fechaRealizado} onChange={set("fechaRealizado")} required />
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-sm font-medium text-gray-700">Descripción *</label>
|
|
||||||
<textarea
|
|
||||||
value={form.descripcion}
|
|
||||||
onChange={set("descripcion")}
|
|
||||||
required
|
|
||||||
rows={2}
|
|
||||||
placeholder="¿Qué se hizo?"
|
|
||||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-sm font-medium text-gray-700">Observaciones</label>
|
|
||||||
<textarea
|
|
||||||
value={form.observaciones}
|
|
||||||
onChange={set("observaciones")}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Notas adicionales..."
|
|
||||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<Button variant="secondary" type="button" onClick={() => setMostrarForm(false)}>Cancelar</Button>
|
|
||||||
<Button type="submit" loading={guardando}>Guardar</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{historial.length === 0 ? (
|
|
||||||
<Card><p className="text-center text-gray-400 py-4">Sin historial registrado.</p></Card>
|
|
||||||
) : (
|
) : (
|
||||||
historial.map((reg) => (
|
turnos.map((t) => (
|
||||||
<Card key={reg.id}>
|
<div key={t.id} className="bg-white border border-gray-200 rounded-2xl px-4 py-3 flex flex-col gap-2 shadow-sm">
|
||||||
<div className="flex items-start justify-between gap-2 flex-wrap mb-2">
|
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-800">{reg.descripcion}</p>
|
<p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
|
||||||
{reg.tipoServicio && (
|
<p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
|
||||||
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">{reg.tipoServicio.nombre}</span>
|
<p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</p>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-400 shrink-0">
|
<Badge estado={t.estado} />
|
||||||
{new Date(reg.fechaRealizado).toLocaleDateString("es-AR", { day: "2-digit", month: "long", year: "numeric" })}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{reg.observaciones && (
|
<p className="text-xs text-gray-400">🔢 {t.kilometros.toLocaleString("es-AR")} km al ingreso</p>
|
||||||
<p className="text-sm text-gray-500 bg-gray-50 rounded px-3 py-2">{reg.observaciones}</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>
|
</div>
|
||||||
</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/historial", label: "Historial", icon: "📋" },
|
||||||
{ href: "/admin/clientes", label: "Clientes", icon: "👥" },
|
{ href: "/admin/clientes", label: "Clientes", icon: "👥" },
|
||||||
{ href: "/admin/servicios", label: "Servicios", icon: "⚙️" },
|
{ href: "/admin/servicios", label: "Servicios", icon: "⚙️" },
|
||||||
|
{ href: "/admin/jefes", label: "Jefes", icon: "🔧" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -21,18 +22,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
if (pathname === "/admin") return <>{children}</>;
|
if (pathname === "/admin") return <>{children}</>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
|
||||||
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
|
||||||
<span className="font-bold text-blue-600 text-lg">🔧 Taller — Admin</span>
|
|
||||||
<button onClick={cerrarSesion} className="text-sm text-gray-500 hover:text-red-600 transition">
|
|
||||||
Cerrar sesión
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col md:flex-row gap-6 py-6">
|
<div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col md:flex-row gap-6 py-6">
|
||||||
<nav className="md:w-48 shrink-0">
|
<nav className="md:w-48 shrink-0 bg-black/50 backdrop-blur-sm md:bg-transparent rounded-xl md:rounded-none p-2 md:p-0">
|
||||||
<ul className="flex md:flex-col gap-2 flex-wrap">
|
<ul className="flex md:flex-col gap-2 flex-wrap">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<li key={item.href}>
|
<li key={item.href}>
|
||||||
@@ -41,7 +33,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition ${
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition ${
|
||||||
pathname.startsWith(item.href)
|
pathname.startsWith(item.href)
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue-600 text-white"
|
||||||
: "text-gray-700 hover:bg-gray-100"
|
: "text-gray-100 hover:bg-white/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{item.icon}</span>
|
<span>{item.icon}</span>
|
||||||
@@ -49,6 +41,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
<li className="md:mt-auto">
|
||||||
|
<button
|
||||||
|
onClick={cerrarSesion}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-300 hover:text-red-300 hover:bg-white/10 transition w-full"
|
||||||
|
>
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function AdminLoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
<Card className="w-full max-w-sm mx-4">
|
<Card className="w-full max-w-sm mx-4">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<div className="text-4xl mb-2">🔧</div>
|
<div className="text-4xl mb-2">🔧</div>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function ServiciosPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Tipos de servicio</h1>
|
<h1 className="text-2xl font-bold text-white">Tipos de servicio</h1>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => { setMostrarForm(true); setEditando(null); setForm({ nombre: "", duracionHoras: "" }); }}
|
onClick={() => { setMostrarForm(true); setEditando(null); setForm({ nombre: "", duracionHoras: "" }); }}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
@@ -98,7 +98,7 @@ export default function ServiciosPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-gray-400">Cargando...</div>
|
<div className="text-center py-10 text-gray-200">Cargando...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{servicios.map((s) => (
|
{servicios.map((s) => (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function GET(req: NextRequest) {
|
|||||||
OR: [
|
OR: [
|
||||||
{ nombre: { contains: q, mode: "insensitive" } },
|
{ nombre: { contains: q, mode: "insensitive" } },
|
||||||
{ apellido: { contains: q, mode: "insensitive" } },
|
{ apellido: { contains: q, mode: "insensitive" } },
|
||||||
{ email: { contains: q, mode: "insensitive" } },
|
{ autos: { some: { patente: { contains: q, mode: "insensitive" } } } },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ export async function GET(req: NextRequest) {
|
|||||||
const autoId = searchParams.get("autoId");
|
const autoId = searchParams.get("autoId");
|
||||||
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
|
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
|
||||||
|
|
||||||
const historial = await prisma.historialServicio.findMany({
|
const turnos = await prisma.turno.findMany({
|
||||||
where: { autoId },
|
where: { autoId },
|
||||||
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
|
include: { tipoServicio: { select: { nombre: true } } },
|
||||||
orderBy: { fechaRealizado: "desc" },
|
orderBy: { fechaInicio: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(historial);
|
return NextResponse.json(turnos);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { verificarSesion } from "@/lib/auth-admin";
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
import { agregarFilaSheet } from "@/lib/google-sheets";
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const sesion = await verificarSesion("admin_session");
|
const sesion = await verificarSesion("admin_session");
|
||||||
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { estado } = await req.json();
|
const { estado, comentario } = await req.json();
|
||||||
|
|
||||||
const turno = await prisma.turno.update({
|
const turno = await prisma.turno.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { estado },
|
data: { estado, ...(comentario !== undefined && { comentario }) },
|
||||||
include: { auto: true, tipoServicio: true, cliente: true },
|
include: { auto: true, tipoServicio: true, cliente: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (estado === "COMPLETADO") {
|
||||||
|
try {
|
||||||
|
await agregarFilaSheet({
|
||||||
|
patente: turno.auto.patente,
|
||||||
|
kilometros: turno.kilometros,
|
||||||
|
marca: turno.auto.marca,
|
||||||
|
modelo: turno.auto.modelo,
|
||||||
|
anio: turno.auto.anio,
|
||||||
|
nombre: turno.cliente.nombre,
|
||||||
|
apellido: turno.cliente.apellido,
|
||||||
|
email: turno.cliente.email,
|
||||||
|
telefono: turno.cliente.telefono,
|
||||||
|
fecha: turno.fechaInicio,
|
||||||
|
trabajo: turno.tipoServicio.nombre,
|
||||||
|
comentario: turno.comentario,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error escribiendo en Google Sheet:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(turno);
|
return NextResponse.json(turno);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
const sesion = await verificarSesion("cliente_session");
|
||||||
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
const { patente, marca, modelo, anio } = await req.json();
|
const { patente, marca, modelo, anio, nroChasis } = await req.json();
|
||||||
if (!patente || !marca || !modelo || !anio)
|
if (!patente || !marca || !modelo || !anio)
|
||||||
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
|
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ export async function POST(req: NextRequest) {
|
|||||||
marca,
|
marca,
|
||||||
modelo,
|
modelo,
|
||||||
anio: Number(anio),
|
anio: Number(anio),
|
||||||
|
nroChasis: nroChasis?.trim() || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ export async function GET(req: NextRequest) {
|
|||||||
const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } });
|
const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } });
|
||||||
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
|
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
|
||||||
|
|
||||||
const historial = await prisma.historialServicio.findMany({
|
const turnos = await prisma.turno.findMany({
|
||||||
where: { autoId },
|
where: { autoId },
|
||||||
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
|
include: { tipoServicio: { select: { nombre: true } } },
|
||||||
orderBy: { fechaRealizado: "desc" },
|
orderBy: { fechaInicio: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(historial);
|
return NextResponse.json(turnos);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Resend } from "resend";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: "smtp.gmail.com",
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: process.env.GMAIL_USER,
|
||||||
|
pass: process.env.GMAIL_APP_PASSWORD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { email, nombre, apellido, telefono } = await req.json();
|
const { email, nombre, apellido, telefono } = await req.json();
|
||||||
@@ -17,9 +25,9 @@ export async function POST(req: NextRequest) {
|
|||||||
data: { email: email.toLowerCase().trim(), nombre, apellido, telefono },
|
data: { email: email.toLowerCase().trim(), nombre, apellido, telefono },
|
||||||
});
|
});
|
||||||
|
|
||||||
await resend.emails.send({
|
await transporter.sendMail({
|
||||||
from: "Taller <onboarding@resend.dev>",
|
from: `"Taller" <${process.env.GMAIL_USER}>`,
|
||||||
to: "jpbeldomenico@gmail.com",
|
to: email,
|
||||||
subject: "¡Bienvenido al taller!",
|
subject: "¡Bienvenido al taller!",
|
||||||
html: `
|
html: `
|
||||||
<h2>¡Hola ${nombre}!</h2>
|
<h2>¡Hola ${nombre}!</h2>
|
||||||
|
|||||||
@@ -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 { searchParams } = new URL(req.url);
|
||||||
const tipoServicioId = searchParams.get("tipoServicioId");
|
const tipoServicioId = searchParams.get("tipoServicioId");
|
||||||
|
const jefeMecanicoId = searchParams.get("jefeMecanicoId");
|
||||||
if (!tipoServicioId)
|
if (!tipoServicioId)
|
||||||
return NextResponse.json({ error: "tipoServicioId requerido" }, { status: 400 });
|
return NextResponse.json({ error: "tipoServicioId requerido" }, { status: 400 });
|
||||||
|
if (!jefeMecanicoId)
|
||||||
|
return NextResponse.json({ error: "jefeMecanicoId requerido" }, { status: 400 });
|
||||||
|
|
||||||
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
|
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
|
||||||
if (!servicio || !servicio.activo)
|
if (!servicio || !servicio.activo)
|
||||||
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 });
|
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 });
|
||||||
|
|
||||||
const slots = await obtenerSlotsDisponibles(servicio.duracionHoras);
|
const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500);
|
||||||
return NextResponse.json(slots);
|
return NextResponse.json(slots);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export async function GET() {
|
|||||||
|
|
||||||
const turnos = await prisma.turno.findMany({
|
const turnos = await prisma.turno.findMany({
|
||||||
where: { clienteId: sesion.sub },
|
where: { clienteId: sesion.sub },
|
||||||
include: { auto: true, tipoServicio: true },
|
include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } },
|
||||||
orderBy: { fechaInicio: "desc" },
|
orderBy: { fechaInicio: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,10 +20,14 @@ export async function POST(req: NextRequest) {
|
|||||||
const sesion = await verificarSesion("cliente_session");
|
const sesion = await verificarSesion("cliente_session");
|
||||||
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
const { autoId, tipoServicioId, fechaInicio } = await req.json();
|
const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json();
|
||||||
if (!autoId || !tipoServicioId || !fechaInicio)
|
if (!autoId || !tipoServicioId || !jefeMecanicoId || !fechaInicio || !kilometros)
|
||||||
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
|
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
|
||||||
|
|
||||||
|
const km = Number(kilometros);
|
||||||
|
if (!Number.isInteger(km) || km <= 0 || km > 1000000)
|
||||||
|
return NextResponse.json({ error: "Kilometraje inválido (debe ser entre 1 y 1.000.000)" }, { status: 400 });
|
||||||
|
|
||||||
// Verificar que el auto pertenece al cliente
|
// Verificar que el auto pertenece al cliente
|
||||||
const auto = await prisma.auto.findFirst({
|
const auto = await prisma.auto.findFirst({
|
||||||
where: { id: autoId, clienteId: sesion.sub },
|
where: { id: autoId, clienteId: sesion.sub },
|
||||||
@@ -34,16 +38,28 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!servicio || !servicio.activo)
|
if (!servicio || !servicio.activo)
|
||||||
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 });
|
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 });
|
||||||
|
|
||||||
|
const jefe = await prisma.jefeMecanico.findUnique({ where: { id: jefeMecanicoId } });
|
||||||
|
if (!jefe || !jefe.activo)
|
||||||
|
return NextResponse.json({ error: "Jefe mecánico no disponible" }, { status: 404 });
|
||||||
|
|
||||||
const inicio = new Date(fechaInicio);
|
const inicio = new Date(fechaInicio);
|
||||||
const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
|
const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
|
||||||
|
|
||||||
const disponible = await slotEstaDisponible(inicio, fin);
|
const disponible = await slotEstaDisponible(inicio, fin, jefeMecanicoId);
|
||||||
if (!disponible)
|
if (!disponible)
|
||||||
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
|
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
|
||||||
|
|
||||||
const turno = await prisma.turno.create({
|
const turno = await prisma.turno.create({
|
||||||
data: { clienteId: sesion.sub, autoId, tipoServicioId, fechaInicio: inicio, fechaFin: fin },
|
data: {
|
||||||
include: { auto: true, tipoServicio: true },
|
clienteId: sesion.sub,
|
||||||
|
autoId,
|
||||||
|
tipoServicioId,
|
||||||
|
jefeMecanicoId,
|
||||||
|
fechaInicio: inicio,
|
||||||
|
fechaFin: fin,
|
||||||
|
kilometros: km,
|
||||||
|
},
|
||||||
|
include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(turno, { status: 201 });
|
return NextResponse.json(turno, { status: 201 });
|
||||||
|
|||||||
@@ -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" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-gray-500">Verificando tu acceso...</p>
|
<p className="text-gray-200">Verificando tu acceso...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,9 +39,9 @@ function AuthContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
|
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
|
||||||
<div className="text-5xl">❌</div>
|
<div className="text-5xl">❌</div>
|
||||||
<h1 className="text-xl font-bold text-gray-800">Link inválido o expirado</h1>
|
<h1 className="text-xl font-bold text-white">Link inválido o expirado</h1>
|
||||||
<p className="text-gray-500">El link de acceso ya fue usado o expiró. Pedí uno nuevo.</p>
|
<p className="text-gray-200">El link de acceso ya fue usado o expiró. Pedí uno nuevo.</p>
|
||||||
<a href="/cliente" className="text-blue-600 hover:underline font-medium">
|
<a href="/cliente" className="text-blue-300 hover:underline font-medium">
|
||||||
Volver al inicio
|
Volver al inicio
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
|||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
import Card from "@/components/ui/Card";
|
import Card from "@/components/ui/Card";
|
||||||
import Input from "@/components/ui/Input";
|
import Input from "@/components/ui/Input";
|
||||||
|
import Combobox from "@/components/ui/Combobox";
|
||||||
|
import { MARCAS, getModelos } from "@/lib/marcas-modelos";
|
||||||
|
|
||||||
interface Auto {
|
interface Auto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +14,7 @@ interface Auto {
|
|||||||
marca: string;
|
marca: string;
|
||||||
modelo: string;
|
modelo: string;
|
||||||
anio: number;
|
anio: number;
|
||||||
|
nroChasis?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
@@ -19,7 +22,8 @@ export default function DashboardPage() {
|
|||||||
const [autos, setAutos] = useState<Auto[]>([]);
|
const [autos, setAutos] = useState<Auto[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [mostrarForm, setMostrarForm] = useState(false);
|
const [mostrarForm, setMostrarForm] = useState(false);
|
||||||
const [form, setForm] = useState({ patente: "", marca: "", modelo: "", anio: "" });
|
const [form, setForm] = useState({ patente: "", marca: "", modelo: "", anio: "", nroChasis: "" });
|
||||||
|
const modelosDisponibles = getModelos(form.marca);
|
||||||
const [guardando, setGuardando] = useState(false);
|
const [guardando, setGuardando] = useState(false);
|
||||||
const [errorForm, setErrorForm] = useState("");
|
const [errorForm, setErrorForm] = useState("");
|
||||||
|
|
||||||
@@ -51,7 +55,7 @@ export default function DashboardPage() {
|
|||||||
setGuardando(false);
|
setGuardando(false);
|
||||||
if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; }
|
if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; }
|
||||||
setMostrarForm(false);
|
setMostrarForm(false);
|
||||||
setForm({ patente: "", marca: "", modelo: "", anio: "" });
|
setForm({ patente: "", marca: "", modelo: "", anio: "", nroChasis: "" });
|
||||||
cargarAutos();
|
cargarAutos();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,19 +67,19 @@ export default function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Mis autos</h1>
|
<h1 className="text-2xl font-bold text-white">Mis autos</h1>
|
||||||
<button onClick={cerrarSesion} className="text-sm text-gray-400 hover:text-red-500 transition">
|
<button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
|
||||||
Salir
|
Salir
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-gray-400">Cargando...</div>
|
<div className="text-center py-10 text-gray-200">Cargando...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{autos.map((auto) => (
|
{autos.map((auto) => (
|
||||||
<Card key={auto.id} className="hover:shadow-md transition">
|
<Card key={auto.id} className="hover:shadow-md transition">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-800">
|
<p className="font-semibold text-gray-800">
|
||||||
{auto.marca} {auto.modelo} <span className="text-gray-500">({auto.anio})</span>
|
{auto.marca} {auto.modelo} <span className="text-gray-500">({auto.anio})</span>
|
||||||
@@ -83,13 +87,13 @@ export default function DashboardPage() {
|
|||||||
<p className="text-sm text-gray-500 font-mono mt-0.5">{auto.patente}</p>
|
<p className="text-sm text-gray-500 font-mono mt-0.5">{auto.patente}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href={`/cliente/historial?autoId=${auto.id}`}>
|
<Link href={`/cliente/historial?autoId=${auto.id}`} className="flex-1 sm:flex-none">
|
||||||
<Button variant="secondary" className="text-sm">
|
<Button variant="secondary" className="text-sm w-full sm:w-auto">
|
||||||
📋 Historial
|
📋 Historial
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/cliente/turnos?autoId=${auto.id}`}>
|
<Link href={`/cliente/turnos?autoId=${auto.id}`} className="flex-1 sm:flex-none">
|
||||||
<Button className="text-sm">
|
<Button className="text-sm w-full sm:w-auto">
|
||||||
📅 Sacar turno
|
📅 Sacar turno
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -99,7 +103,7 @@ export default function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{autos.length === 0 && !mostrarForm && (
|
{autos.length === 0 && !mostrarForm && (
|
||||||
<p className="text-center text-gray-400 py-4">No tenés autos registrados todavía.</p>
|
<p className="text-center text-gray-200 py-4">No tenés autos registrados todavía.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -108,10 +112,30 @@ export default function DashboardPage() {
|
|||||||
<Card title="Agregar auto">
|
<Card title="Agregar auto">
|
||||||
<form onSubmit={agregarAuto} className="flex flex-col gap-4">
|
<form onSubmit={agregarAuto} className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input id="marca" label="Marca" placeholder="Ford" value={form.marca} onChange={set("marca")} required />
|
<Combobox
|
||||||
<Input id="modelo" label="Modelo" placeholder="Focus" value={form.modelo} onChange={set("modelo")} required />
|
id="marca"
|
||||||
|
label="Marca"
|
||||||
|
options={MARCAS}
|
||||||
|
value={form.marca}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, marca: v, modelo: "" }))}
|
||||||
|
onSelect={(v) => setForm((f) => ({ ...f, marca: v, modelo: "" }))}
|
||||||
|
placeholder="Ford"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
id="modelo"
|
||||||
|
label="Modelo"
|
||||||
|
options={modelosDisponibles}
|
||||||
|
value={form.modelo}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, modelo: v }))}
|
||||||
|
onSelect={(v) => setForm((f) => ({ ...f, modelo: v }))}
|
||||||
|
placeholder={form.marca ? "Focus" : "Primero elegí la marca"}
|
||||||
|
disabled={modelosDisponibles.length === 0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Input id="anio" label="Año" type="number" placeholder="2018" value={form.anio} onChange={set("anio")} required min={1960} max={new Date().getFullYear() + 1} />
|
<Input id="anio" label="Año" type="number" placeholder="2018" value={form.anio} onChange={set("anio")} required min={1960} max={new Date().getFullYear() + 1} />
|
||||||
<Input id="patente" label="Patente" placeholder="AB123CD" value={form.patente} onChange={set("patente")} required className="uppercase" />
|
<Input id="patente" label="Patente" placeholder="AB123CD" value={form.patente} onChange={set("patente")} required className="uppercase" />
|
||||||
|
<Input id="nroChasis" label="Nº Chasis (opcional)" placeholder="8AFZZZ11ZYM569874" value={form.nroChasis} onChange={set("nroChasis")} className="col-span-2" />
|
||||||
</div>
|
</div>
|
||||||
{errorForm && <p className="text-sm text-red-500">{errorForm}</p>}
|
{errorForm && <p className="text-sm text-red-500">{errorForm}</p>}
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
|
|||||||
@@ -2,23 +2,24 @@
|
|||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Card from "@/components/ui/Card";
|
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
|
import Badge from "@/components/ui/Badge";
|
||||||
|
|
||||||
interface Registro {
|
interface Turno {
|
||||||
id: string;
|
id: string;
|
||||||
descripcion: string;
|
fechaInicio: string;
|
||||||
observaciones: string | null;
|
fechaFin: string;
|
||||||
fechaRealizado: string;
|
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
||||||
admin: { nombre: string };
|
kilometros: number;
|
||||||
tipoServicio: { nombre: string } | null;
|
comentario: string | null;
|
||||||
|
tipoServicio: { nombre: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistorialContent() {
|
function HistorialContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const autoId = params.get("autoId");
|
const autoId = params.get("autoId");
|
||||||
const [historial, setHistorial] = useState<Registro[]>([]);
|
const [turnos, setTurnos] = useState<Turno[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,53 +30,61 @@ function HistorialContent() {
|
|||||||
return r;
|
return r;
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => { setHistorial(data); setLoading(false); })
|
.then((data) => { setTurnos(data); setLoading(false); })
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
}, [autoId, router]);
|
}, [autoId, router]);
|
||||||
|
|
||||||
|
function formatFecha(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString("es-AR", {
|
||||||
|
weekday: "long", day: "2-digit", month: "long", year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHora(iso: string) {
|
||||||
|
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/cliente/dashboard">
|
<Link href="/cliente/dashboard">
|
||||||
<Button variant="ghost" className="px-2">← Volver</Button>
|
<Button variant="ghost" className="px-2">← Volver</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Historial del auto</h1>
|
<h1 className="text-xl sm:text-2xl font-bold text-white">Historial del auto</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-gray-400">Cargando...</div>
|
<div className="text-center py-10 text-gray-200">Cargando...</div>
|
||||||
) : historial.length === 0 ? (
|
) : turnos.length === 0 ? (
|
||||||
<Card>
|
<div className="bg-white border border-gray-200 rounded-2xl p-8 text-center">
|
||||||
<p className="text-center text-gray-400 py-4">Este auto no tiene historial registrado todavía.</p>
|
<p className="text-4xl mb-3">🔧</p>
|
||||||
</Card>
|
<p className="text-gray-500 font-medium">Sin turnos registrados</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Los turnos aparecerán aquí cuando reservés uno.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{historial.map((reg) => (
|
{turnos.map((t) => (
|
||||||
<Card key={reg.id}>
|
<div key={t.id} className="bg-white border border-gray-200 rounded-2xl px-4 py-3 flex flex-col gap-2 shadow-sm">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
<div>
|
||||||
<div>
|
<p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
|
||||||
<p className="font-semibold text-gray-800">{reg.descripcion}</p>
|
<p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
|
||||||
{reg.tipoServicio && (
|
<p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{reg.observaciones && (
|
<Badge estado={t.estado} />
|
||||||
<p className="text-sm text-gray-500 bg-gray-50 rounded-lg px-3 py-2">
|
|
||||||
{reg.observaciones}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-gray-400">Realizado por: {reg.admin.nombre}</p>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -85,7 +94,7 @@ function HistorialContent() {
|
|||||||
|
|
||||||
export default function HistorialPage() {
|
export default function HistorialPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="text-center py-10 text-gray-400">Cargando...</div>}>
|
<Suspense fallback={<div className="text-center py-10 text-gray-200">Cargando...</div>}>
|
||||||
<HistorialContent />
|
<HistorialContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,13 +6,8 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function ClienteLayout({ children }: { children: React.ReactNode }) {
|
export default function ClienteLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen">
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
<main className="max-w-2xl mx-auto px-4 py-8">{children}</main>
|
||||||
<div className="max-w-2xl mx-auto px-4 h-14 flex items-center justify-between">
|
|
||||||
<span className="font-bold text-blue-600 text-lg">🔧 Taller</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="max-w-2xl mx-auto px-4 py-6">{children}</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ export default function ClienteLoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
|
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
|
||||||
<div className="text-5xl">📧</div>
|
<div className="text-5xl">📧</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Revisá tu email</h1>
|
<h1 className="text-2xl font-bold text-white">Revisá tu email</h1>
|
||||||
<p className="text-gray-500 max-w-sm">
|
<p className="text-gray-200 max-w-sm">
|
||||||
Te enviamos un link de acceso a <strong>{email}</strong>. El link expira en 15 minutos.
|
Te enviamos un link de acceso a <strong>{email}</strong>. El link expira en 15 minutos.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="text-sm text-blue-600 hover:underline mt-2"
|
className="text-sm text-blue-300 hover:underline mt-2"
|
||||||
onClick={() => { setEnviado(false); setEmail(""); }}
|
onClick={() => { setEnviado(false); setEmail(""); }}
|
||||||
>
|
>
|
||||||
Usar otro email
|
Usar otro email
|
||||||
|
|||||||
+343
-96
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Card from "@/components/ui/Card";
|
import Card from "@/components/ui/Card";
|
||||||
@@ -7,17 +7,59 @@ import Button from "@/components/ui/Button";
|
|||||||
import Badge from "@/components/ui/Badge";
|
import Badge from "@/components/ui/Badge";
|
||||||
|
|
||||||
interface TipoServicio { id: string; nombre: string; duracionHoras: number; }
|
interface TipoServicio { id: string; nombre: string; duracionHoras: number; }
|
||||||
|
interface Jefe { id: string; nombre: string; }
|
||||||
interface Slot { fechaInicio: string; fechaFin: string; }
|
interface Slot { fechaInicio: string; fechaFin: string; }
|
||||||
interface Turno {
|
interface Turno {
|
||||||
id: string;
|
id: string; fechaInicio: string; fechaFin: string;
|
||||||
fechaInicio: string;
|
|
||||||
fechaFin: string;
|
|
||||||
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
||||||
tipoServicio: { nombre: string };
|
tipoServicio: { nombre: string };
|
||||||
auto: { marca: string; modelo: string; patente: string };
|
auto: { marca: string; modelo: string; patente: string };
|
||||||
|
jefeMecanico?: { nombre: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step = "servicio" | "slot" | "confirmado";
|
const MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
|
||||||
|
const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"];
|
||||||
|
|
||||||
|
type Step = "servicio" | "jefe" | "calendario" | "confirmado";
|
||||||
|
|
||||||
|
function formatDuracion(h: number) {
|
||||||
|
if (h < 1) return `${Math.round(h * 60)} min`;
|
||||||
|
if (h === 1) return "1 hora";
|
||||||
|
if (Number.isInteger(h)) return `${h} horas`;
|
||||||
|
return `${Math.floor(h)} h ${Math.round((h % 1) * 60)} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convierte ISO UTC a fecha Argentina "YYYY-MM-DD"
|
||||||
|
function fechaArg(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString("sv-SE", { timeZone: "America/Argentina/Buenos_Aires" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convierte ISO UTC a hora Argentina "HH:MM"
|
||||||
|
function horaArg(iso: string) {
|
||||||
|
return new Date(iso).toLocaleTimeString("es-AR", {
|
||||||
|
timeZone: "America/Argentina/Buenos_Aires", hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hoy en Argentina "YYYY-MM-DD"
|
||||||
|
function hoyArg() {
|
||||||
|
return new Date().toLocaleDateString("sv-SE", { timeZone: "America/Argentina/Buenos_Aires" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Celdas del mes (null = celda vacía antes del día 1)
|
||||||
|
function celdasMes(year: number, month: number): (number | null)[] {
|
||||||
|
const primer = new Date(year, month, 1);
|
||||||
|
const total = new Date(year, month + 1, 0).getDate();
|
||||||
|
const offset = (primer.getDay() + 6) % 7; // Lunes = 0
|
||||||
|
const result: (number | null)[] = Array(offset).fill(null);
|
||||||
|
for (let d = 1; d <= total; d++) result.push(d);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esFinDeSemana(year: number, month: number, day: number) {
|
||||||
|
const dow = new Date(year, month, day).getDay();
|
||||||
|
return dow === 0 || dow === 6;
|
||||||
|
}
|
||||||
|
|
||||||
function TurnosContent() {
|
function TurnosContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -27,75 +69,165 @@ function TurnosContent() {
|
|||||||
const [step, setStep] = useState<Step>("servicio");
|
const [step, setStep] = useState<Step>("servicio");
|
||||||
const [servicios, setServicios] = useState<TipoServicio[]>([]);
|
const [servicios, setServicios] = useState<TipoServicio[]>([]);
|
||||||
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
|
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
|
||||||
|
const [jefes, setJefes] = useState<Jefe[]>([]);
|
||||||
|
const [jefeSel, setJefeSel] = useState<Jefe | null>(null);
|
||||||
|
const [loadingJefes, setLoadingJefes] = useState(false);
|
||||||
const [slots, setSlots] = useState<Slot[]>([]);
|
const [slots, setSlots] = useState<Slot[]>([]);
|
||||||
const [slotSel, setSlotSel] = useState<Slot | null>(null);
|
|
||||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||||
const [reservando, setReservando] = useState(false);
|
|
||||||
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null);
|
|
||||||
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
|
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
|
||||||
const [loadingTurnos, setLoadingTurnos] = useState(true);
|
const [loadingTurnos, setLoadingTurnos] = useState(true);
|
||||||
|
|
||||||
|
// Calendario
|
||||||
|
const hoy = new Date();
|
||||||
|
const [mesVista, setMesVista] = useState({ year: hoy.getFullYear(), month: hoy.getMonth() });
|
||||||
|
const [diaSeleccionado, setDiaSeleccionado] = useState<string | null>(null);
|
||||||
|
const [slotSel, setSlotSel] = useState<Slot | null>(null);
|
||||||
|
const [kilometros, setKilometros] = useState("");
|
||||||
|
const [kmError, setKmError] = useState("");
|
||||||
|
const [reservando, setReservando] = useState(false);
|
||||||
|
const [reservaError, setReservaError] = useState("");
|
||||||
|
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoId) { router.push("/cliente/dashboard"); return; }
|
if (!autoId) { router.push("/cliente/dashboard"); return; }
|
||||||
fetch("/api/admin/servicios")
|
fetch("/api/cliente/servicios").then(r => r.json()).then(setServicios);
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => setServicios(data.filter((s: TipoServicio & { activo: boolean }) => s.activo)));
|
|
||||||
fetch("/api/cliente/turnos")
|
fetch("/api/cliente/turnos")
|
||||||
.then((r) => r.json())
|
.then(r => r.json())
|
||||||
.then((data) => { setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId)); setLoadingTurnos(false); });
|
.then((data) => {
|
||||||
|
setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId));
|
||||||
|
setLoadingTurnos(false);
|
||||||
|
});
|
||||||
}, [autoId, router]);
|
}, [autoId, router]);
|
||||||
|
|
||||||
|
// Agrupar slots por fecha Argentina
|
||||||
|
const slotsPorDia = useMemo(() => {
|
||||||
|
const m: Record<string, Slot[]> = {};
|
||||||
|
for (const s of slots) {
|
||||||
|
const d = fechaArg(s.fechaInicio);
|
||||||
|
if (!m[d]) m[d] = [];
|
||||||
|
m[d].push(s);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [slots]);
|
||||||
|
|
||||||
async function seleccionarServicio(s: TipoServicio) {
|
async function seleccionarServicio(s: TipoServicio) {
|
||||||
setServicioSel(s);
|
setServicioSel(s);
|
||||||
setLoadingSlots(true);
|
setJefeSel(null);
|
||||||
setStep("slot");
|
setSlots([]);
|
||||||
const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}`);
|
setDiaSeleccionado(null);
|
||||||
|
setSlotSel(null);
|
||||||
|
setLoadingJefes(true);
|
||||||
|
setStep("jefe");
|
||||||
|
const res = await fetch("/api/cliente/jefes");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSlots(data);
|
setJefes(Array.isArray(data) ? data : []);
|
||||||
|
setLoadingJefes(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seleccionarJefe(j: Jefe) {
|
||||||
|
if (!servicioSel) return;
|
||||||
|
setJefeSel(j);
|
||||||
|
setLoadingSlots(true);
|
||||||
|
setSlots([]);
|
||||||
|
setDiaSeleccionado(null);
|
||||||
|
setSlotSel(null);
|
||||||
|
setStep("calendario");
|
||||||
|
const res = await fetch(`/api/cliente/slots?tipoServicioId=${servicioSel.id}&jefeMecanicoId=${j.id}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setSlots(Array.isArray(data) ? data : []);
|
||||||
setLoadingSlots(false);
|
setLoadingSlots(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seleccionarDia(dateStr: string) {
|
||||||
|
setDiaSeleccionado(dateStr);
|
||||||
|
setSlotSel(null);
|
||||||
|
setKilometros("");
|
||||||
|
setKmError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKilometros(val: string) {
|
||||||
|
setKilometros(val);
|
||||||
|
if (val === "") {
|
||||||
|
setKmError("");
|
||||||
|
} else if (!/^\d+$/.test(val) || Number(val) <= 0) {
|
||||||
|
setKmError("Ingresá solo números enteros positivos");
|
||||||
|
} else if (Number(val) > 1000000) {
|
||||||
|
setKmError("El kilometraje no puede superar 1.000.000 km");
|
||||||
|
} else {
|
||||||
|
setKmError("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function reservar() {
|
async function reservar() {
|
||||||
if (!slotSel || !servicioSel || !autoId) return;
|
if (!slotSel || !servicioSel || !jefeSel || !autoId || !kilometros || kmError) return;
|
||||||
setReservando(true);
|
setReservando(true);
|
||||||
|
setReservaError("");
|
||||||
const res = await fetch("/api/cliente/turnos", {
|
const res = await fetch("/api/cliente/turnos", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ autoId, tipoServicioId: servicioSel.id, fechaInicio: slotSel.fechaInicio }),
|
body: JSON.stringify({
|
||||||
|
autoId,
|
||||||
|
tipoServicioId: servicioSel.id,
|
||||||
|
jefeMecanicoId: jefeSel.id,
|
||||||
|
fechaInicio: slotSel.fechaInicio,
|
||||||
|
kilometros: Number(kilometros),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setReservando(false);
|
setReservando(false);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setTurnoCreado(data);
|
setTurnoCreado(data);
|
||||||
|
setDiaSeleccionado(null);
|
||||||
setStep("confirmado");
|
setStep("confirmado");
|
||||||
|
} else {
|
||||||
|
setReservaError(data?.error || "Ocurrió un error al reservar. Intentá de nuevo.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSlot(slot: Slot) {
|
// Navegación de mes
|
||||||
const inicio = new Date(slot.fechaInicio);
|
const hoyStr = hoyArg();
|
||||||
const fin = new Date(slot.fechaFin);
|
const mesActual = { year: hoy.getFullYear(), month: hoy.getMonth() };
|
||||||
const dia = inicio.toLocaleDateString("es-AR", { weekday: "long", day: "numeric", month: "long" });
|
const puedeRetroceder = mesVista.year > mesActual.year || (mesVista.year === mesActual.year && mesVista.month > mesActual.month);
|
||||||
const h1 = inicio.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
|
||||||
const h2 = fin.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
function prevMes() {
|
||||||
return { dia: dia.charAt(0).toUpperCase() + dia.slice(1), horas: `${h1} - ${h2}` };
|
if (!puedeRetroceder) return;
|
||||||
|
setMesVista(m => {
|
||||||
|
if (m.month === 0) return { year: m.year - 1, month: 11 };
|
||||||
|
return { year: m.year, month: m.month - 1 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextMes() {
|
||||||
|
setMesVista(m => {
|
||||||
|
if (m.month === 11) return { year: m.year + 1, month: 0 };
|
||||||
|
return { year: m.year, month: m.month + 1 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const celdas = celdasMes(mesVista.year, mesVista.month);
|
||||||
|
const slotsDelDia = diaSeleccionado ? (slotsPorDia[diaSeleccionado] ?? []) : [];
|
||||||
|
|
||||||
if (step === "confirmado" && turnoCreado) {
|
if (step === "confirmado" && turnoCreado) {
|
||||||
const { dia, horas } = formatSlot({ fechaInicio: turnoCreado.fechaInicio, fechaFin: turnoCreado.fechaFin });
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center gap-4">
|
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center gap-4">
|
||||||
<div className="text-5xl">✅</div>
|
<div className="text-5xl">✅</div>
|
||||||
<h1 className="text-2xl font-bold text-gray-800">¡Turno reservado!</h1>
|
<h1 className="text-2xl font-bold text-white">¡Turno reservado!</h1>
|
||||||
<Card className="text-left w-full max-w-sm">
|
<Card className="text-left w-full max-w-sm">
|
||||||
<p className="text-sm text-gray-500 mb-1">Servicio</p>
|
<p className="text-sm text-gray-500 mb-1">Servicio</p>
|
||||||
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
|
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
|
||||||
|
{turnoCreado.jefeMecanico && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Jefe mecánico</p>
|
||||||
|
<p className="font-semibold mb-3">{turnoCreado.jefeMecanico.nombre}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<p className="text-sm text-gray-500 mb-1">Fecha</p>
|
<p className="text-sm text-gray-500 mb-1">Fecha</p>
|
||||||
<p className="font-semibold mb-1">{dia}</p>
|
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
|
||||||
<p className="text-gray-600">{horas}</p>
|
timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long"
|
||||||
|
})}</p>
|
||||||
|
<p className="text-gray-600">{horaArg(turnoCreado.fechaInicio)} – {horaArg(turnoCreado.fechaFin)}</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Link href="/cliente/dashboard">
|
<Link href="/cliente/dashboard"><Button variant="secondary">← Volver a mis autos</Button></Link>
|
||||||
<Button variant="secondary">← Volver a mis autos</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,30 +235,30 @@ function TurnosContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{step === "slot" ? (
|
{step === "calendario" ? (
|
||||||
|
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
|
||||||
|
) : step === "jefe" ? (
|
||||||
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
|
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
|
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
|
||||||
)}
|
)}
|
||||||
<h1 className="text-2xl font-bold text-gray-800">
|
<h1 className="text-xl sm:text-2xl font-bold text-white">
|
||||||
{step === "servicio" ? "Elegí el servicio" : "Elegí el horario"}
|
{step === "servicio" && "Elegí el servicio"}
|
||||||
|
{step === "jefe" && "Elegí el jefe mecánico"}
|
||||||
|
{step === "calendario" && `${servicioSel?.nombre}${jefeSel ? ` · ${jefeSel.nombre}` : ""}`}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* STEP: servicio */}
|
||||||
{step === "servicio" && (
|
{step === "servicio" && (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{servicios.map((s) => (
|
{servicios.map(s => (
|
||||||
<button
|
<button key={s.id} onClick={() => seleccionarServicio(s)}
|
||||||
key={s.id}
|
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between">
|
||||||
onClick={() => seleccionarServicio(s)}
|
|
||||||
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-800">{s.nombre}</p>
|
<p className="font-medium text-gray-800">{s.nombre}</p>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
|
||||||
Duración: {s.duracionHoras === 1 ? "1 hora" : `${s.duracionHoras} horas`}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-gray-400 text-lg">→</span>
|
<span className="text-gray-400 text-lg">→</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -136,78 +268,193 @@ function TurnosContent() {
|
|||||||
{!loadingTurnos && misTurnos.length > 0 && (
|
{!loadingTurnos && misTurnos.length > 0 && (
|
||||||
<Card title="Mis turnos para este auto">
|
<Card title="Mis turnos para este auto">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{misTurnos.map((t) => {
|
{misTurnos.map(t => (
|
||||||
const { dia, horas } = formatSlot({ fechaInicio: t.fechaInicio, fechaFin: t.fechaFin });
|
<div key={t.id} className="flex items-center justify-between flex-wrap gap-2">
|
||||||
return (
|
<div>
|
||||||
<div key={t.id} className="flex items-center justify-between flex-wrap gap-2">
|
<p className="text-sm font-medium">{t.tipoServicio.nombre}</p>
|
||||||
<div>
|
<p className="text-xs text-gray-500">
|
||||||
<p className="text-sm font-medium">{t.tipoServicio.nombre}</p>
|
{new Date(t.fechaInicio).toLocaleDateString("es-AR", {
|
||||||
<p className="text-xs text-gray-500">{dia} · {horas}</p>
|
timeZone: "America/Argentina/Buenos_Aires",
|
||||||
</div>
|
weekday: "short", day: "numeric", month: "short"
|
||||||
<Badge estado={t.estado} />
|
})} · {horaArg(t.fechaInicio)} – {horaArg(t.fechaFin)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
<Badge estado={t.estado} />
|
||||||
})}
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "slot" && (
|
{/* STEP: jefe */}
|
||||||
<div className="flex flex-col gap-4">
|
{step === "jefe" && (
|
||||||
{loadingSlots ? (
|
<div className="flex flex-col gap-3">
|
||||||
<div className="text-center py-10 text-gray-400">Buscando horarios disponibles...</div>
|
{loadingJefes ? (
|
||||||
) : slots.length === 0 ? (
|
<p className="text-center text-sm text-gray-200">Cargando jefes...</p>
|
||||||
|
) : jefes.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-center text-gray-400 py-4">
|
<p className="text-sm text-gray-600 text-center">
|
||||||
No hay horarios disponibles en los próximos 14 días. Consultá al taller.
|
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
jefes.map((j) => (
|
||||||
<p className="text-sm text-gray-500">
|
<button
|
||||||
Servicio: <strong>{servicioSel?.nombre}</strong>
|
key={j.id}
|
||||||
</p>
|
onClick={() => seleccionarJefe(j)}
|
||||||
<div className="grid gap-2">
|
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
|
||||||
{slots.map((slot, i) => {
|
|
||||||
const { dia, horas } = formatSlot(slot);
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
Confirmar turno
|
<div>
|
||||||
</Button>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TurnosPage() {
|
export default function TurnosPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="text-center py-10 text-gray-400">Cargando...</div>}>
|
<Suspense fallback={<div className="text-center py-10 text-gray-200">Cargando...</div>}>
|
||||||
<TurnosContent />
|
<TurnosContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
+8
-9
@@ -3,20 +3,19 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background: var(--background);
|
background-image:
|
||||||
|
linear-gradient(rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.35)),
|
||||||
|
url('/tallermg.jpeg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
min-height: 100vh;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Taller MG",
|
||||||
description: "Generated by create next app",
|
description: "Sistema de turnos - Taller MG",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -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;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function crearSesionJefe(jefeId: string): Promise<string> {
|
||||||
|
const token = await new SignJWT({ sub: jefeId, role: "jefe" })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setExpirationTime("8h")
|
||||||
|
.sign(SECRET);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
export async function verificarSesion(
|
export async function verificarSesion(
|
||||||
cookieName: string
|
cookieName: string
|
||||||
): Promise<{ sub: string; role: string } | null> {
|
): Promise<{ sub: string; role: string } | null> {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
const SLOTS_PARALELOS = 4; // 2 jefes × 2 autos c/u
|
const INICIO_H = 8;
|
||||||
const HORA_INICIO = 8;
|
const INICIO_M = 30;
|
||||||
const HORA_FIN = 18;
|
const FIN_H = 17;
|
||||||
|
const FIN_M = 0;
|
||||||
|
const INICIO_MINUTOS = INICIO_H * 60 + INICIO_M; // 510
|
||||||
|
const FIN_MINUTOS = FIN_H * 60 + FIN_M; // 1020
|
||||||
|
|
||||||
|
// Argentina es siempre UTC-3 (sin horario de verano desde 2008)
|
||||||
|
const ARG_OFFSET_MIN = -180;
|
||||||
|
|
||||||
|
// Minutos del día en hora Argentina (0-1439)
|
||||||
|
function minutosArgentina(fecha: Date): number {
|
||||||
|
const utcMin = fecha.getUTCHours() * 60 + fecha.getUTCMinutes();
|
||||||
|
return ((utcMin + ARG_OFFSET_MIN) + 1440) % 1440;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Día de la semana en Argentina (0=Dom, 6=Sab)
|
||||||
|
function diaArgentina(fecha: Date): number {
|
||||||
|
const arg = new Date(fecha.getTime() + ARG_OFFSET_MIN * 60 * 1000);
|
||||||
|
return arg.getUTCDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componentes de fecha Argentina (para construir fechas UTC)
|
||||||
|
function fechaArgentina(fecha: Date): { year: number; month: number; date: number } {
|
||||||
|
const arg = new Date(fecha.getTime() + ARG_OFFSET_MIN * 60 * 1000);
|
||||||
|
return { year: arg.getUTCFullYear(), month: arg.getUTCMonth(), date: arg.getUTCDate() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve la fecha UTC correspondiente a las 8:30 Argentina del día que contiene `fecha`
|
||||||
|
function inicioJornada(fecha: Date): Date {
|
||||||
|
const { year, month, date } = fechaArgentina(fecha);
|
||||||
|
// 8:30 Argentina + 3h = 11:30 UTC
|
||||||
|
return new Date(Date.UTC(year, month, date, INICIO_H - ARG_OFFSET_MIN / 60, INICIO_M, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
function esDiaHabil(fecha: Date): boolean {
|
function esDiaHabil(fecha: Date): boolean {
|
||||||
const dia = fecha.getDay(); // 0=Dom, 6=Sab
|
const dia = diaArgentina(fecha);
|
||||||
return dia >= 1 && dia <= 5;
|
return dia >= 1 && dia <= 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
|
function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
|
||||||
const h1 = inicio.getHours() + inicio.getMinutes() / 60;
|
const iMin = minutosArgentina(inicio);
|
||||||
const h2 = fin.getHours() + fin.getMinutes() / 60;
|
const fMin = minutosArgentina(fin);
|
||||||
return h1 >= HORA_INICIO && h2 <= HORA_FIN;
|
const iArg = fechaArgentina(inicio);
|
||||||
|
const fArg = fechaArgentina(fin);
|
||||||
|
const mismodia =
|
||||||
|
iArg.year === fArg.year && iArg.month === fArg.month && iArg.date === fArg.date;
|
||||||
|
return mismodia && iMin >= INICIO_MINUTOS && fMin <= FIN_MINUTOS;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devuelve cuántos turnos activos se superponen con el rango dado
|
async function turnosSolapados(
|
||||||
async function turnosSolapados(inicio: Date, fin: Date): Promise<number> {
|
inicio: Date,
|
||||||
|
fin: Date,
|
||||||
|
jefeMecanicoId: string
|
||||||
|
): Promise<number> {
|
||||||
const count = await prisma.turno.count({
|
const count = await prisma.turno.count({
|
||||||
where: {
|
where: {
|
||||||
|
jefeMecanicoId,
|
||||||
estado: { in: ["PENDIENTE", "CONFIRMADO"] },
|
estado: { in: ["PENDIENTE", "CONFIRMADO"] },
|
||||||
AND: [
|
AND: [{ fechaInicio: { lt: fin } }, { fechaFin: { gt: inicio } }],
|
||||||
{ fechaInicio: { lt: fin } },
|
|
||||||
{ fechaFin: { gt: inicio } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function obtenerJefeActivo(jefeMecanicoId: string) {
|
||||||
|
return prisma.jefeMecanico.findUnique({
|
||||||
|
where: { id: jefeMecanicoId },
|
||||||
|
select: { slotsParalelos: true, activo: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface SlotDisponible {
|
export interface SlotDisponible {
|
||||||
fechaInicio: Date;
|
fechaInicio: Date;
|
||||||
fechaFin: Date;
|
fechaFin: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Busca los próximos N slots disponibles para una duración dada (en horas)
|
|
||||||
export async function obtenerSlotsDisponibles(
|
export async function obtenerSlotsDisponibles(
|
||||||
|
jefeMecanicoId: string,
|
||||||
duracionHoras: number,
|
duracionHoras: number,
|
||||||
diasAdelante = 14
|
diasAdelante = 14,
|
||||||
|
limite = 300
|
||||||
): Promise<SlotDisponible[]> {
|
): Promise<SlotDisponible[]> {
|
||||||
|
const jefe = await obtenerJefeActivo(jefeMecanicoId);
|
||||||
|
if (!jefe || !jefe.activo) return [];
|
||||||
|
const cap = jefe.slotsParalelos;
|
||||||
|
|
||||||
const slots: SlotDisponible[] = [];
|
const slots: SlotDisponible[] = [];
|
||||||
const ahora = new Date();
|
const ahora = new Date();
|
||||||
const duracionMs = duracionHoras * 60 * 60 * 1000;
|
const duracionMs = duracionHoras * 60 * 60 * 1000;
|
||||||
|
|
||||||
const fechaLimite = new Date(ahora);
|
const fechaLimite = new Date(ahora.getTime() + diasAdelante * 24 * 60 * 60 * 1000);
|
||||||
fechaLimite.setDate(fechaLimite.getDate() + diasAdelante);
|
|
||||||
|
|
||||||
|
// Avanzar al próximo slot (:00 o :30 Argentina) a partir de ahora
|
||||||
let cursor = new Date(ahora);
|
let cursor = new Date(ahora);
|
||||||
// Empezar desde el siguiente slot redondo (media hora)
|
const argMNow = minutosArgentina(cursor) % 60;
|
||||||
cursor.setMinutes(cursor.getMinutes() >= 30 ? 60 : 30, 0, 0);
|
const avanzarMin = argMNow < 30 ? 30 - argMNow : 60 - argMNow;
|
||||||
|
cursor = new Date(cursor.getTime() + avanzarMin * 60 * 1000);
|
||||||
|
cursor.setUTCSeconds(0, 0);
|
||||||
|
|
||||||
while (cursor < fechaLimite && slots.length < 20) {
|
while (cursor < fechaLimite && slots.length < limite) {
|
||||||
if (!esDiaHabil(cursor)) {
|
if (!esDiaHabil(cursor)) {
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
cursor = inicioJornada(new Date(cursor.getTime() + 24 * 60 * 60 * 1000));
|
||||||
cursor.setHours(HORA_INICIO, 0, 0, 0);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hInicio = cursor.getHours() + cursor.getMinutes() / 60;
|
const minActual = minutosArgentina(cursor);
|
||||||
if (hInicio < HORA_INICIO) {
|
if (minActual < INICIO_MINUTOS) {
|
||||||
cursor.setHours(HORA_INICIO, 0, 0, 0);
|
cursor = inicioJornada(cursor);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fin = new Date(cursor.getTime() + duracionMs);
|
const fin = new Date(cursor.getTime() + duracionMs);
|
||||||
const hFin = fin.getHours() + fin.getMinutes() / 60;
|
|
||||||
|
|
||||||
if (hFin > HORA_FIN) {
|
if (!horasDentroDeJornada(cursor, fin)) {
|
||||||
// Pasar al día siguiente
|
cursor = inicioJornada(new Date(cursor.getTime() + 24 * 60 * 60 * 1000));
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
|
||||||
cursor.setHours(HORA_INICIO, 0, 0, 0);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ocupados = await turnosSolapados(cursor, fin);
|
const ocupados = await turnosSolapados(cursor, fin, jefeMecanicoId);
|
||||||
if (ocupados < SLOTS_PARALELOS) {
|
if (ocupados < cap) {
|
||||||
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
|
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avanzar 30 minutos
|
|
||||||
cursor = new Date(cursor.getTime() + 30 * 60 * 1000);
|
cursor = new Date(cursor.getTime() + 30 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function slotEstaDisponible(inicio: Date, fin: Date): Promise<boolean> {
|
export async function slotEstaDisponible(
|
||||||
|
inicio: Date,
|
||||||
|
fin: Date,
|
||||||
|
jefeMecanicoId: string
|
||||||
|
): Promise<boolean> {
|
||||||
if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false;
|
if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false;
|
||||||
const ocupados = await turnosSolapados(inicio, fin);
|
const jefe = await obtenerJefeActivo(jefeMecanicoId);
|
||||||
return ocupados < SLOTS_PARALELOS;
|
if (!jefe || !jefe.activo) return false;
|
||||||
|
const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId);
|
||||||
|
return ocupados < jefe.slotsParalelos;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -28,14 +28,20 @@ export async function middleware(req: NextRequest) {
|
|||||||
pathname.startsWith("/admin/turnos") ||
|
pathname.startsWith("/admin/turnos") ||
|
||||||
pathname.startsWith("/admin/historial") ||
|
pathname.startsWith("/admin/historial") ||
|
||||||
pathname.startsWith("/admin/servicios") ||
|
pathname.startsWith("/admin/servicios") ||
|
||||||
|
pathname.startsWith("/admin/jefes") ||
|
||||||
pathname.startsWith("/admin/clientes")) {
|
pathname.startsWith("/admin/clientes")) {
|
||||||
const ok = await verifyCookie(req, "admin_session");
|
const ok = await verifyCookie(req, "admin_session");
|
||||||
if (!ok) return NextResponse.redirect(new URL("/admin", req.url));
|
if (!ok) return NextResponse.redirect(new URL("/admin", req.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/jefe/dashboard")) {
|
||||||
|
const ok = await verifyCookie(req, "jefe_session");
|
||||||
|
if (!ok) return NextResponse.redirect(new URL("/jefe", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/cliente/:path*", "/admin/:path*"],
|
matcher: ["/cliente/:path*", "/admin/:path*", "/jefe/:path*"],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user