58 Commits

Author SHA1 Message Date
Beldor e0d6035485 fix(bot): saludar como Taller Beldor
Para que el saludo coincida con el Display Name de la cuenta de
WhatsApp Business y con el nombre del sitio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 17:18:08 -03:00
Beldor d917618e67 fix(sitio): nombrar el sitio Taller Beldor
Meta rechaza el alta de WhatsApp (#2494070) cuando el nombre declarado
del negocio no coincide con el del sitio que lo respalda. El negocio es
Taller Beldor y el sitio decia Taller MG.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 17:17:06 -03:00
jpbeldomenico 8fcd0fbf77 Merge pull request (#8): pasar las credenciales de Meta al contenedor
Deploy / deploy (push) Successful in 4m47s
2026-08-07 17:09:43 -03:00
Beldor 31bd0ad0bd fix(whatsapp): pasar las credenciales de Meta al contenedor
Revisión con IA / ai-review (pull_request) Successful in 47s
docker-compose.yml no interpolaba ninguna WHATSAPP_*, asi que las cuatro
variables cargadas en el .env de produccion no llegaban a la app: los
envios cortaban con "falta WHATSAPP_TOKEN" y el webhook devolvia 403 al
no tener APP_SECRET con que validar la firma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:07:21 -03:00
jpbeldomenico 2bdf76ab63 Merge pull request (#7): comprobar el rol de la sesion en todas las rutas
Deploy / deploy (push) Successful in 3m5s
2026-08-05 17:02:50 -03:00
Beldor f4df90ac1e fix(auth): comprobar el rol de la sesion en todas las rutas
Revisión con IA / ai-review (pull_request) Successful in 1m24s
Las tres sesiones (admin, cliente, jefe) se firman con el mismo secreto, asi que
verificar solo la firma alcanzaba para que un token de un rol pasara por la
cookie de otro. Verificado antes del cambio: un token de admin en
cliente_session daba 200 en /api/cliente/autos, y uno de jefe cargaba
/cliente/dashboard. Despues: 401 y 307.

- Las 26 llamadas a verificarSesion pasan el rol esperado.
- El middleware, que hacia su propio jwtVerify sin mirar el rol, ahora tambien
  lo comprueba.
- Se sacan las comprobaciones sueltas de rol en las rutas de jefe, que quedaron
  redundantes.

Matriz probada contra la app: los tres roles siguen entrando en su area (200) y
ninguno entra en la de los otros, ni por API (401) ni por pagina (307).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:45:33 -03:00
jpbeldomenico 97f9ccec28 Merge pull request (#6): datos de contacto del cliente
Deploy / deploy (push) Successful in 3m4s
2026-08-05 16:09:45 -03:00
Beldor ce7063514c fix(perfil): detalles senalados en la revision
Revisión con IA / ai-review (pull_request) Successful in 24s
- verificarSesion acepta el rol esperado. Las tres sesiones (admin, cliente,
  jefe) se firman con el mismo secreto, asi que un token de jefe puesto en la
  cookie cliente_session pasaba la verificacion de firma; ahora el rol se
  comprueba. Verificado: con un JWT de jefe, /api/cliente/perfil da 401.
- El update traduce el P2002 de los indices unicos a un 409 legible, por si otra
  request toma el mismo email o telefono entre la comprobacion y el update.
- La pantalla ya no queda en "Cargando..." para siempre si falla el pedido.
- Si la sesion vence mientras edita, el PATCH manda a entrar de nuevo en vez de
  mostrar un error suelto.

Queda como mejora aparte pasarle el rol esperado al resto de las rutas del area
del cliente, que hoy solo validan la firma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:09:40 -03:00
Beldor 62dab5e025 feat(cliente): ver y modificar los datos de contacto
Revisión con IA / ai-review (pull_request) Successful in 2m18s
El cliente no tenia forma de corregir su email ni su telefono despues del alta.
Nueva pantalla /cliente/perfil ("Mis datos", enlazada desde el dashboard) que
muestra nombre, email y telefono, y permite editarlos.

- GET/PATCH /api/cliente/perfil, ambos con la sesion del cliente.
- El telefono usa el mismo campo con el +54 9 fijo que el registro, y se guarda
  normalizado en telefonoNorm para que el bot lo siga reconociendo.
- aLocalArgentino() saca el prefijo para mostrar: un cliente dado de alta por
  WhatsApp tiene el numero guardado como 5491141984844 y en el formulario tiene
  que verse 1141984844.
- Email y telefono se validan unicos contra los demas clientes (409), no contra
  uno mismo. Si cambia el email se avisa que con ese va a entrar de ahora en mas,
  porque es la identidad del magic link.
- El middleware protege /cliente/perfil como el resto del area del cliente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:54:38 -03:00
jpbeldomenico 2baa7952fa Merge pull request (#5): telefono en el formato que usa WhatsApp
Deploy / deploy (push) Successful in 3m0s
2026-08-05 15:03:21 -03:00
Beldor 9e93808dc1 fix: detalles del formato de telefono senalados en la revision
Revisión con IA / ai-review (pull_request) Successful in 25s
- esLocalArgentinoValido tambien rechaza los que arrancan con 15: ninguna
  caracteristica argentina empieza asi, es el viejo prefijo de celular.
- El formulario ya no reta "faltan digitos" mientras se esta tipeando; ese
  aviso espera a que el campo pierda el foco. Los errores seguros (0 o 15
  adelante) se siguen mostrando al instante.
- chat-bot.ts normalizaba el telefono para conversar pero no para /reset, asi
  que el reset no encontraba la conversacion y no borraba nada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:02:59 -03:00
Beldor ffa32a113d fix(registro): guardar el telefono en el formato que usa WhatsApp
Revisión con IA / ai-review (pull_request) Successful in 2m12s
Un cliente registrado por la web no lo reconocia el bot: el formulario dejaba
escribir el numero en cualquier formato (1141984844, 01157260394) mientras que
Meta manda el wa_id 5491141984844, asi que la busqueda por telefonoNorm no
encontraba nada, mandaba al alta y ahi chocaba contra el email ya registrado.
Quedaba en un callejon sin salida.

Ahora el campo muestra "+54 9" fijo y grisado y el cliente completa solo la
caracteristica (sin el 0) y el abonado (sin el 15), 10 digitos. El backend
valida ese formato y guarda telefonoNorm ya como lo espera el bot. Se rechaza
tambien un telefono repetido, que antes reventaba con el indice unico.

Se agrega scripts/chat-bot.ts, que es como se encontro esto: permite conversar
con el bot desde la consola contra cualquier base, sin Meta ni credenciales.
Corriendolo contra una copia de la base de produccion se ve el ciclo completo:
alta por web -> el bot lo reconoce por telefono -> pide el email -> reserva.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:56:15 -03:00
jpbeldomenico e85635f272 Merge pull request (#4): bot de reservas por WhatsApp y flujo web jefe -> trabajo
Deploy / deploy (push) Successful in 4m52s
2026-08-05 12:24:53 -03:00
Beldor 2df679777c fix(jefe): mensaje de duracion acorde a lo que valida
Revisión con IA / ai-review (pull_request) Successful in 31s
La validacion rechaza 0 pero el mensaje decia "entre 0 y 24". Ojo que
"entre 1 y 24" habria sido igual de incorrecto: duracionHoras es Float y la
UI muestra las duraciones de menos de una hora en minutos, asi que 0,5 (media
hora) es un valor valido y esperado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:21:02 -03:00
Beldor 91112862a5 fix: los cuatro hallazgos reales de la revision con IA
Revisión con IA / ai-review (pull_request) Successful in 29s
- crearTurno verificaba disponibilidad y creaba el turno en dos operaciones
  sueltas: dos clientes podian pasar la verificacion a la vez y sobrevender el
  mismo horario. Ahora va todo en una transaccion, serializada por jefe con
  pg_advisory_xact_lock. slotEstaDisponible acepta el cliente de la transaccion.
  Probado contra un Postgres real: 5 reservas simultaneas del mismo slot con
  capacidad 1 dan 1 ok + 4 conflictos y una sola fila; sacando el lock, entran
  las 5.
- La deduplicacion del webhook estaba en memoria y solo servia con una
  instancia. Pasa a la base con el modelo MensajeWhatsappProcesado: el insert
  del id es atomico por el indice unico, asi que aguanta reintentos
  concurrentes de Meta (verificado). Se limpian los ids de mas de 7 dias.
- postMensaje no reintentaba: ahora reintenta 3 veces ante 429, 5xx y fallas
  de red, con backoff exponencial y jitter. Un 4xx nuestro no se reintenta.
- /api/cliente/servicios devolvia los trabajos de un jefe dado de baja; ahora
  responde 404 si el jefe no existe o no esta activo.

test:envio suma los casos de reintento (35 asserts). El Prisma en memoria del
test del bot ahora entiende $transaction y $executeRaw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:21:35 -03:00
Beldor 44aeb58e77 feat(cliente): invertir el flujo web a jefe -> trabajo
Revisión con IA / ai-review (pull_request) Failing after 2m12s
Los trabajos ahora son por jefe mecanico, asi que el cliente elige primero
el mecanico y despues el trabajo, igual que en el bot de WhatsApp. Antes
listaba todos los servicios del taller y recien despues pedia el jefe.

- src/lib/servicios.ts: regla unica de que trabajos ofrece un jefe = los
  suyos + los globales del taller (jefeMecanicoId null, los carga el admin).
  Asi no se rompe lo que ya existe en produccion, donde todos los servicios
  son globales.
- /api/cliente/servicios ahora exige jefeMecanicoId y aplica esa regla.
- /api/cliente/slots y crearTurno rechazan un trabajo propio de OTRO jefe
  (400): antes se podia reservar con el jefe A la duracion del trabajo del B.
- whatsapp-bot: usa la misma regla, asi la web y el bot ofrecen lo mismo.
- cliente/turnos: pasos invertidos, cabeceras y back acordes, y estado vacio
  propio cuando el jefe no tiene trabajos.
- test-whatsapp-bot: soporte de OR en el Prisma en memoria, trabajo global en
  el seed y escenario D (globales visibles, trabajo ajeno rechazado). 31 asserts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:55:47 -03:00
jpbeldomenico 15d6e8a2ee feat(whatsapp): webhook + envio por Graph API (F0)
Ultima fase del bot de reservas: conecta la maquina de conversacion ya
existente (whatsapp-bot.ts) con la WhatsApp Business Cloud API.

- src/lib/whatsapp.ts: traduce RespuestaBot -> payloads de Meta (texto e
  interactive/button, con los limites de 3 botones / 20 chars / 4096 chars),
  aplana el payload del webhook y valida X-Hub-Signature-256 en tiempo
  constante.
- src/app/api/whatsapp/webhook/route.ts: GET de verificacion y POST de
  mensajes. Valida la firma sobre el cuerpo crudo, deduplica por message id
  (Meta reintenta) y siempre devuelve 200 para no entrar en loop de retries.
- scripts/test-whatsapp-envio.ts (npm run test:envio): 30 asserts sobre la
  capa Meta con fetch stubbeado, sin DB ni credenciales.
- docs/whatsapp.md con el alta en el panel de Meta y las 4 env vars.

Falta solo cargar las credenciales de Meta en el .env de produccion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:55:47 -03:00
Beldor 0046b6c3cf feat(whatsapp): máquina de conversación del bot de reservas (F2) + test
- lib/turnos.ts: crearTurno() compartido (valida servicio/jefe/slot); cliente/turnos POST lo reutiliza
- lib/telefono.ts: normalización de teléfono (E.164 sin '+') para matchear el wa_id
- lib/whatsapp-bot.ts: procesarMensaje(telefono, texto), state machine completa:
  identidad por teléfono + verificación por email registrado / alta nueva,
  elegir mecánico (botones) -> trabajo (letras) -> horario (letras, con "más") -> confirmar,
  crea turno sin auto/km; expiración 30min, "cancelar" global, manejo de inválidos y 409.
  Devuelve objetos-respuesta (texto/botones) -> independiente de Meta (lo traduce F0).
- scripts/test-whatsapp-bot.ts + npm run test:bot: corre la máquina de estados con un
  Prisma en memoria (sin DB ni Meta), ejercitando scheduling y crearTurno reales. 27/27 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 09:55:47 -03:00
Beldor c53e5bb5aa feat(whatsapp): schema del bot de reservas + panel de trabajos por jefe (F1+F3)
FASE 1 — schema:
- Cliente.email opcional; nuevo telefonoNorm (E.164, único) para matchear por WhatsApp
- TipoServicio.jefeMecanicoId opcional (trabajos per-jefe; globales legacy quedan null)
- Turno.autoId/kilometros opcionales (reservas por WhatsApp sin auto/km)
- Nuevo modelo ConversacionWhatsapp (estado del bot por teléfono)
- admin/turnos/[id]: no permite COMPLETADO sin auto+km; Sheets tolera email nulo

FASE 3 — panel de trabajos del jefe:
- API jefe/servicios (GET/POST) y jefe/servicios/[id] (PATCH/DELETE), scoped al jefe
- DELETE desactiva si el trabajo ya tiene turnos asociados
- Página /jefe/servicios (alta/listado/activar/editar/eliminar) + nav Turnos|Trabajos
- Dashboard del jefe resiliente a turnos sin auto/km ("a completar")

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 09:55:47 -03:00
jpbeldomenico dd7f73453e Merge pull request 'ci: reintentar la revision con IA cuando falla el proveedor' (#3) from fix/ai-review-reintentos into main
Deploy / deploy (push) Successful in 3m22s
2026-08-03 22:50:20 -03:00
jpbeldomenico a312815140 fix(ci): no dar por fallida una revision por el texto de su comentario
Revisión con IA / ai-review (pull_request) Successful in 1m59s
El guard buscaba "Error: " en el log, pero el log tambien trae lo que escribe
el modelo. En inventario#3 la revision salio bien y comento justamente que ese
patron era demasiado amplio: el comentario quedo impreso, matcheo el patron y
el job quedo en rojo. Lo encontro la propia revision con IA.

Ahora se miran las marcas de nivel que imprime la herramienta (| ERROR | y
| CRITICAL |), que el modelo no escribe, y el traceback al principio de linea.
Verificado contra los logs reales guardados: detecta la falla por el 502 de
OpenRouter y deja pasar las tres revisiones que salieron bien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:26:21 -03:00
jpbeldomenico a7bb038b6b fix(ci): reintentar la revision con IA cuando falla el proveedor
Revisión con IA / ai-review (pull_request) Successful in 44s
El 03/08/2026 OpenRouter devolvio 502 con el modelo gratuito y el pull
request se mergeo sin revisar. Ahora revisar() reintenta hasta 3 veces con
espera creciente (30s, 60s), pero solo ante las marcas que deja una falla
al pedirle la respuesta al modelo: un 401 o un modelo que ya no existe
siguen cortando en el primer intento, porque esperar no los arregla.

De paso se saca la tuberia a tee: el codigo de salida que se evaluaba era
el de tee, siempre 0, asi que esa mitad del guard nunca hizo nada.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:10:32 -03:00
jpbeldomenico d74c97e532 Merge pull request 'ci: construir antes de bajar el servicio' (#2) from fix/deploy-robusto into main
Deploy / deploy (push) Successful in 4m46s
2026-08-03 20:04:35 -03:00
jpbeldomenico f1ef9da857 fix(ci): construir antes de bajar el servicio
Revisión con IA / ai-review (pull_request) Failing after 24s
El despliegue hacia "docker compose down" y recien despues
"up -d --build". Si la construccion fallaba, el taller quedaba caido hasta
que alguien lo notara: paso el 03/08/2026, cuando npm no pudo bajar las
dependencias y el sitio estuvo abajo sin que ningun paso lo avisara.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:03:13 -03:00
73 changed files with 4549 additions and 507 deletions
+60
View File
@@ -0,0 +1,60 @@
# Configuración de la revisión con IA (https://github.com/Nikita-Filonov/ai-review)
#
# Acá va sólo lo estático. Los tokens y el contexto del pull request llegan por
# variables de entorno desde .gitea/workflows/ai-review.yml, con la convención
# de doble guión bajo que usa la herramienta (VCS__PIPELINE__OWNER, etc.).
# Ojo: la sintaxis ${VARIABLE} dentro de este archivo NO se resuelve.
llm:
provider: OPENROUTER
meta:
# Modelo sin costo, 1M de contexto.
model: nvidia/nemotron-3-ultra-550b-a55b:free
max_tokens: 8000
temperature: 0.2
# Viaja como cabecera HTTP y se codifica en ASCII: sin acentos ni enies.
title: "Revision IA Taller"
http_client:
timeout: 180
api_url: https://openrouter.ai/api/v1
# --- Si este modelo deja de estar disponible ---
# El catalogo gratuito de OpenRouter rota. Si el job falla con 404 de modelo,
# listar los vigentes y reemplazar la clave model:
# curl -s https://openrouter.ai/api/v1/models | grep -o '"id":"[^"]*:free"'
# Alternativas verificadas el 03/08/2026:
# cohere/north-mini-code:free
# nvidia/nemotron-3-super-120b-a12b:free
core:
# El plan gratuito de OpenRouter admite 20 pedidos por minuto.
concurrency: 3
review:
mode: FULL_FILE_DIFF
ignore_pure_renames: true
max_context_comments: 15
max_inline_comments: 10
ignore_changes:
- "package-lock.json"
- "*.lock"
- "*.min.js"
- "*.min.css"
- "*.svg"
- "*.png"
- "*.jpg"
- "*.jpeg"
- "*.ico"
- "*.woff*"
- "public/**"
- "tsconfig.tsbuildinfo"
prompt:
context_prompt_files:
- ./.gitea/ai-review/context.md
summary_prompt_files:
- ./.gitea/ai-review/summary.md
+12 -1
View File
@@ -1,5 +1,16 @@
DATABASE_URL="postgresql://taller:taller123@localhost:5432/taller_turnos" DATABASE_URL="postgresql://taller:taller123@localhost:5432/taller_turnos"
NEXTAUTH_SECRET="cambia-esto-por-un-secret-seguro" NEXTAUTH_SECRET="cambia-esto-por-un-secret-seguro"
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
RESEND_API_KEY="re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" GMAIL_USER="tu-email@gmail.com"
GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
MAGIC_LINK_BASE_URL="http://localhost:3000" MAGIC_LINK_BASE_URL="http://localhost:3000"
# Bot de reservas por WhatsApp (Meta Cloud API).
# PHONE_NUMBER_ID: panel de Meta -> WhatsApp -> API Setup (es un id numérico, NO el teléfono).
# TOKEN: token permanente de un System User con whatsapp_business_messaging + _management.
# VERIFY_TOKEN: string inventado por vos; el mismo va acá y en el panel al dar de alta el webhook.
# APP_SECRET: App -> Settings -> Basic -> App Secret. Sin esto el webhook rechaza todo.
WHATSAPP_PHONE_NUMBER_ID=""
WHATSAPP_TOKEN=""
WHATSAPP_VERIFY_TOKEN=""
WHATSAPP_APP_SECRET=""
+68
View File
@@ -0,0 +1,68 @@
# Revisión de código — Taller Automotriz
Escribí siempre en español rioplatense, directo y sin rodeos.
Recibís **todos los archivos cambiados del pull request juntos**, no uno por
vez. Aprovechalo: además de los errores dentro de cada archivo, buscá los que
sólo se ven mirando el conjunto.
## Contexto del proyecto
Sistema de turnos para un taller mecánico. Next.js (App Router) con TypeScript,
Prisma sobre PostgreSQL:
- `src/app/api` — endpoints.
- `src/app/{admin,jefe,cliente}` — las tres áreas, con permisos distintos.
- `src/lib` — la lógica que importa: `scheduling.ts` y `turnos.ts` (slots y
reservas), `whatsapp-bot.ts` (bot de reservas), `magic-link.ts` y
`auth-admin.ts` (autenticación), `prisma.ts` (cliente).
- `prisma/schema.prisma` — modelo de datos: `Cliente`, `Auto`, `Turno`,
`TipoServicio`, `JefeMecanico`, `HistorialServicio`, `Admin`, `MagicToken`,
`ConversacionWhatsapp`.
## Qué mirar con prioridad
1. **Turnos y disponibilidad**: es el corazón del sistema. Reservas que se
pisan, husos horarios, slots que se calculan distinto en dos lugares,
condiciones de carrera entre dos clientes reservando el mismo horario,
estados de `Turno` que cambian sin validar la transición.
2. **Autorización por área**: `admin`, `jefe` y `cliente` ven cosas distintas.
Un endpoint que no verifica el rol en el backend, o que confía en lo que
manda el cliente, es un hallazgo grave. Los magic links son credenciales:
token que no expira, que se reutiliza o que se registra en un log.
3. **Bugs reales**: promesas sin `await`, `null`/`undefined` sin contemplar,
errores tragados, casos borde sin cubrir.
4. **Prisma**: migraciones que no acompañan al cambio de `schema.prisma`,
consultas dentro de un bucle (N+1), operaciones que deberían ir en una
transacción y no lo están.
5. **Coherencia entre archivos**: un tipo que cambia en `src/lib` y deja
desactualizado a quien lo consume, lógica de negocio duplicada entre un
endpoint y el bot de WhatsApp en vez de vivir en `src/lib`.
## Cuidados propios de este proyecto
- Los datos de clientes (teléfono, email, patente) son personales: no deben
terminar en logs ni en respuestas de error.
- El bot de WhatsApp responde a mensajes de afuera: todo lo que llega por ahí
es entrada no confiable y hay que validarlo.
- Nunca claves ni tokens en el código, los documentos o los mensajes.
## Antes de afirmar
Si tu observación depende de cómo se comporta una función del lenguaje, de
Next.js o de Prisma, verificá que estás seguro. Si no lo estás, plantealo como
pregunta ("¿confirmaste que X hace Y?") en vez de afirmarlo. Un señalamiento
correcto apoyado en una premisa falsa hace perder tiempo y resta credibilidad al
resto de la revisión.
## Qué no comentar
- Formato, comillas, punto y coma o largo de línea: de eso se ocupa el linter.
- Preferencias de estilo sin impacto en el comportamiento.
- Renombres de variables porque te suenan mejor.
- Reescrituras completas de un archivo que ya funciona.
- Elogios o comentarios que sólo describen lo que hace el código.
Como ves muchos archivos de una sola vez, resistí la tentación de comentar en
todos para parecer completo. Si de veinte archivos sólo tres tienen algo que
decir, comentá esos tres. Un comentario ruidoso cuesta más que uno faltante.
+23
View File
@@ -0,0 +1,23 @@
# Resumen del pull request — J2 ProDE
Escribí en español rioplatense. Sé breve: quien lee quiere decidir si aprobar o
no, no leer una redacción.
Estructura:
1. **Qué cambia** — dos o tres oraciones sobre la intención del cambio, no un
listado de archivos tocados.
2. **Riesgos** — sólo si los hay de verdad. Priorizá: violaciones de las
invariantes del proyecto, cambios de contrato en `packages/contracts`,
migraciones de PostgreSQL, autorización, y todo lo que pueda romper un
despliegue en curso.
3. **Antes de aprobar** — verificaciones concretas que convenga correr a mano si
el gate (`pnpm check`) no las cubre.
Reglas:
- Si no hay riesgos, decilo en una línea y terminá.
- No repitas el diff ni parafrasees cada archivo.
- No felicites al autor.
- Ante la duda de si algo es un problema, decilo como duda y explicá qué
verificar, sin afirmar que está roto.
+244
View File
@@ -0,0 +1,244 @@
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.
#
# Cuidado con QUE se busca: el log tambien trae el texto de los
# comentarios que escribe el modelo, y ese texto puede decir
# cualquier cosa. El patron 'Error: ' que habia antes se disparo con
# el comentario de la propia revision (inventario#3) y marco como
# fallida una revision que habia salido bien. Por eso ahora se miran
# las marcas de nivel que imprime la herramienta, que el modelo no
# escribe, y el traceback al principio de linea.
ERRORES='\| (ERROR|CRITICAL) \||^Traceback \(most recent call last\)'
# El plan gratuito de OpenRouter devuelve 502 cuando los proveedores
# del modelo estan saturados; pasó el 03/08/2026 y el pull request se
# mergeo sin revisar. Eso se reintenta, pero un 401 o un modelo que ya
# no existe no se arreglan esperando: por eso solo reintentamos con
# las marcas que deja una falla al pedirle la respuesta al modelo.
# Todas ocurren antes de publicar nada, asi que el reintento no puede
# dejar comentarios repetidos.
PASAJEROS='OpenRouterChatResponseSchema|LLM returned empty|Upstream|Too Many Requests|timed out'
INTENTOS=3
PRIMERA_ESPERA=30
revisar() {
modo="$1"
registro="/tmp/ai-review-${modo}.log"
intento=1
espera="${PRIMERA_ESPERA}"
while : ; do
echo "== ai-review ${modo} (intento ${intento} de ${INTENTOS})"
# Sin tuberia a proposito: con "| tee" el codigo que se evalua es
# el de tee, que siempre da 0, y la falla pasaba desapercibida.
salida=0
ai-review "${modo}" > "${registro}" 2>&1 || salida=$?
cat "${registro}"
if [ "${salida}" -eq 0 ] && ! grep -qE "${ERRORES}" "${registro}"; then
return 0
fi
if [ "${intento}" -ge "${INTENTOS}" ] || ! grep -qE "${PASAJEROS}" "${registro}"; then
echo "La revisión reportó errores en '${modo}' pese a terminar sin código de error." >&2
return 1
fi
echo "Falla pasajera del proveedor; reintento en ${espera}s." >&2
sleep "${espera}"
intento=$((intento + 1))
espera=$((espera * 2))
done
}
# Para depurar la configuración existe "ai-review show-config", pero
# imprime los tokens resueltos: no correrlo dentro del pipeline.
if [ "${REVIEW_COMMAND}" = "auto" ]; then
# La revisión por contexto manda todos los archivos en un único
# pedido y aun así deja los comentarios pegados a cada línea, así
# que el pull request completo cuesta dos pedidos al modelo.
revisar run-context
revisar run-summary
else
revisar "${REVIEW_COMMAND}"
fi
+42
View File
@@ -0,0 +1,42 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: taller
steps:
- name: Pull y rebuild
run: |
set -eu
git config --global --add safe.directory /home/proyecto/taller_automotriz
cd /home/proyecto/taller_automotriz
git fetch origin
git reset --hard origin/main
# Construir ANTES de tocar lo que esta corriendo. Antes este paso
# hacia "docker compose down" y recien despues "up --build": si la
# construccion fallaba (por ejemplo, npm sin poder bajar las
# dependencias), el taller quedaba caido hasta que alguien lo
# notara. Pasó el 03/08/2026.
docker compose build
# Con la imagen nueva ya lista, "up -d" recrea los contenedores que
# cambiaron. No hace falta bajar nada de antemano.
docker compose up -d --no-build --remove-orphans
- name: Verificar que quedo arriba
run: |
set -eu
cd /home/proyecto/taller_automotriz
docker compose ps
# Si el contenedor no esta corriendo, el despliegue fallo aunque los
# comandos anteriores hayan salido bien.
if [ -z "$(docker compose ps --quiet --status running)" ]; then
echo "El despliegue termino sin contenedores corriendo"
docker compose logs --tail 50
exit 1
fi
+12
View File
@@ -28,6 +28,18 @@ 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}
# Bot de WhatsApp. Sin estas cuatro el .env de produccion no llega al
# contenedor: los envios avisan que falta el token y el webhook contesta
# 403 porque no puede validar la firma. Ver docs/whatsapp.md.
WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID}
WHATSAPP_TOKEN: ${WHATSAPP_TOKEN}
WHATSAPP_VERIFY_TOKEN: ${WHATSAPP_VERIFY_TOKEN}
WHATSAPP_APP_SECRET: ${WHATSAPP_APP_SECRET}
ports: ports:
- "3004:3000" - "3004:3000"
+53
View File
@@ -0,0 +1,53 @@
# Bot de reservas por WhatsApp
Flujo: el cliente escribe al número del taller → el bot lo identifica por teléfono (verifica
con el email registrado, o lo da de alta) → elige mecánico, trabajo y horario → confirma y se
crea el Turno. Todo pasa dentro de la ventana de 24hs que abre el cliente, así que **no hacen
falta plantillas aprobadas** ni tiene costo.
## Piezas
| Archivo | Qué hace |
|---|---|
| `src/lib/whatsapp-bot.ts` | Máquina de conversación. No sabe nada de Meta: devuelve `RespuestaBot[]` |
| `src/lib/whatsapp.ts` | Traduce a/desde la Graph API de Meta y valida la firma del webhook |
| `src/app/api/whatsapp/webhook/route.ts` | Endpoint: verificación (GET) y mensajes entrantes (POST) |
Tests sin DB ni credenciales: `npm run test:bot` (conversación) y `npm run test:envio` (capa Meta).
## Variables de entorno
```
WHATSAPP_PHONE_NUMBER_ID= # id numérico del número, NO el teléfono
WHATSAPP_TOKEN= # token permanente de System User
WHATSAPP_VERIFY_TOKEN= # string inventado, el mismo acá y en el panel de Meta
WHATSAPP_APP_SECRET= # App -> Settings -> Basic -> App Secret
```
Sin `WHATSAPP_APP_SECRET` el webhook rechaza todo con 403 (es a propósito: sin firma no se
puede confiar en el payload).
## Alta en Meta (una sola vez)
1. Meta Business Suite con la cuenta de Facebook del taller.
2. developers.facebook.com → crear App tipo **Business** → agregar producto **WhatsApp**.
3. **WhatsApp → API Setup**: de ahí salen `PHONE_NUMBER_ID` y un token temporal (24hs).
Para el token definitivo: Business Settings → System Users → crear usuario → Generate Token
con permisos `whatsapp_business_messaging` y `whatsapp_business_management`.
4. **App → Settings → Basic → App Secret** → `WHATSAPP_APP_SECRET`.
5. Cargar las 4 variables en el `.env` de producción (`/home/proyecto/taller_automotriz/.env`,
al lado del `docker-compose.yml`, que es de donde compose las interpola) y levantar de nuevo
con `docker compose up -d`. Un `git push` no alcanza: el `.env` no está en el repo.
6. **WhatsApp → Configuration → Webhook**:
- Callback URL: `https://taller.beldo.duckdns.org/api/whatsapp/webhook`
- Verify token: el mismo `WHATSAPP_VERIFY_TOKEN`
- Suscribir el campo **`messages`** (sin esto no llega nada).
7. Probar con el número de prueba que da Meta gratis, antes de migrar el número real.
Notas:
- El número del taller no puede estar activo en la app de WhatsApp normal/Business: hay que
borrar esa cuenta antes de migrarlo a Cloud API.
- Business Verification se saltea mientras estemos abajo de 1000 usuarios únicos/24hs; solo
hace falta la aprobación del **Display Name**.
- Meta permite 3 botones como máximo. Si hay más de 3 mecánicos activos, salen los 3 primeros
como botón y el resto se elige respondiendo con el número (el bot acepta las dos formas).
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
set -e set -e
echo "Corriendo migraciones..." echo "Corriendo migraciones..."
node node_modules/prisma/build/index.js migrate deploy node node_modules/prisma/build/index.js db push --accept-data-loss
echo "Migraciones OK. Iniciando app..." echo "Migraciones OK. Iniciando app..."
exec node_modules/.bin/next start exec node_modules/.bin/next start
+344 -52
View File
@@ -10,11 +10,14 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@types/nodemailer": "^8.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"googleapis": "^173.0.0",
"jose": "^6.2.3", "jose": "^6.2.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"next": "14.2.35", "next": "14.2.35",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"nodemailer": "^6.10.1",
"prisma": "^7.8.0", "prisma": "^7.8.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
@@ -708,7 +711,6 @@
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^5.1.2", "string-width": "^5.1.2",
@@ -726,7 +728,6 @@
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -739,7 +740,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.2.2" "ansi-regex": "^6.2.2"
@@ -1036,7 +1036,6 @@
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
@@ -1502,6 +1501,15 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -2164,6 +2172,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2185,7 +2202,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -2195,7 +2211,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -2478,7 +2493,26 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true, "license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcryptjs": { "node_modules/bcryptjs": {
@@ -2496,6 +2530,15 @@
"integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2638,7 +2681,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -2652,7 +2694,6 @@
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -2782,7 +2823,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -2795,7 +2835,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@@ -2870,6 +2909,15 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/data-view-buffer": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -2928,7 +2976,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -3058,7 +3105,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1", "call-bind-apply-helpers": "^1.0.1",
@@ -3073,7 +3119,6 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": { "node_modules/ecdsa-sig-formatter": {
@@ -3099,7 +3144,6 @@
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/empathic": { "node_modules/empathic": {
@@ -3215,7 +3259,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -3225,7 +3268,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -3263,7 +3305,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -3812,6 +3853,12 @@
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-check": { "node_modules/fast-check": {
"version": "3.23.2", "version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
@@ -3916,6 +3963,29 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -4013,6 +4083,18 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -4039,7 +4121,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -4079,6 +4160,34 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/gaxios": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz",
"integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/generate-function": { "node_modules/generate-function": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@@ -4102,7 +4211,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.2", "call-bind-apply-helpers": "^1.0.2",
@@ -4133,7 +4241,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dunder-proto": "^1.0.1", "dunder-proto": "^1.0.1",
@@ -4188,7 +4295,6 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
"integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"foreground-child": "^3.1.0", "foreground-child": "^3.1.0",
@@ -4224,7 +4330,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -4234,7 +4339,6 @@
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.2" "brace-expansion": "^2.0.2"
@@ -4279,11 +4383,114 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/google-auth-library": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz",
"integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis": {
"version": "173.0.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-173.0.0.tgz",
"integrity": "sha512-xEJJYLZ4qeenVyfzispNfRjCe9bsv7CzBv5zYFLvScOze9snJ8S9W6hjQ729CWPQt5mvn/JrcRaCHzQiukt0ng==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.2.0",
"googleapis-common": "^8.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.2.tgz",
"integrity": "sha512-5MXeQzIZaqCH7B+HJWqhQm946VARpZep6acbWSr/fcgF2cQANq7allgX+i/G0EqF0WyUxB277gtWMzRYHMl9tg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"gaxios": "7.1.3",
"google-auth-library": "10.5.0",
"google-logging-utils": "1.1.3",
"qs": "^6.7.0",
"url-template": "^2.0.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/googleapis-common/node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common/node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common/node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4317,6 +4524,19 @@
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -4373,7 +4593,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4402,7 +4621,6 @@
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -4426,6 +4644,19 @@
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -4717,7 +4948,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -5005,7 +5235,6 @@
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
"integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/cliui": "^8.0.2" "@isaacs/cliui": "^8.0.2"
@@ -5068,6 +5297,15 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -5312,7 +5550,6 @@
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/lru.min": { "node_modules/lru.min": {
@@ -5334,7 +5571,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5391,7 +5627,6 @@
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -5593,6 +5828,26 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-exports-info": { "node_modules/node-exports-info": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -5622,6 +5877,33 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -5665,7 +5947,6 @@
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -5911,7 +6192,6 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
@@ -6463,6 +6743,21 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6940,7 +7235,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -6960,7 +7254,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -6977,7 +7270,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -6996,7 +7288,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bound": "^1.0.2", "call-bound": "^1.0.2",
@@ -7100,7 +7391,6 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"eastasianwidth": "^0.2.0", "eastasianwidth": "^0.2.0",
@@ -7119,7 +7409,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -7134,14 +7423,12 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string-width/node_modules/ansi-regex": { "node_modules/string-width/node_modules/ansi-regex": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -7154,7 +7441,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.2.2" "ansi-regex": "^6.2.2"
@@ -7284,7 +7570,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -7298,7 +7583,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -7802,6 +8086,12 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/url-template": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -7823,6 +8113,15 @@
} }
} }
}, },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7941,7 +8240,6 @@
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^6.1.0", "ansi-styles": "^6.1.0",
@@ -7960,7 +8258,6 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
@@ -7978,14 +8275,12 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/wrap-ansi-cjs/node_modules/string-width": { "node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -8000,7 +8295,6 @@
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -8013,7 +8307,6 @@
"version": "6.2.3", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -8026,7 +8319,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.2.2" "ansi-regex": "^6.2.2"
+6 -1
View File
@@ -10,7 +10,9 @@
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:seed": "npx tsx prisma/seed.ts", "db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio" "db:studio": "prisma studio",
"test:bot": "tsx scripts/test-whatsapp-bot.ts",
"test:envio": "tsx scripts/test-whatsapp-envio.ts"
}, },
"prisma": { "prisma": {
"seed": "npx tsx prisma/seed.ts" "seed": "npx tsx prisma/seed.ts"
@@ -18,11 +20,14 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@types/nodemailer": "^8.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"googleapis": "^173.0.0",
"jose": "^6.2.3", "jose": "^6.2.3",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"next": "14.2.35", "next": "14.2.35",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"nodemailer": "^6.10.1",
"prisma": "^7.8.0", "prisma": "^7.8.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
+63 -17
View File
@@ -7,16 +7,18 @@ datasource db {
} }
model Cliente { model Cliente {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String? @unique
nombre String nombre String
apellido String apellido String
telefono String telefono String
createdAt DateTime @default(now()) telefonoNorm String? @unique // teléfono en formato E.164, usado para matchear al que escribe por WhatsApp
createdAt DateTime @default(now())
autos Auto[] autos Auto[]
turnos Turno[] turnos Turno[]
magicTokens MagicToken[] magicTokens MagicToken[]
conversaciones ConversacionWhatsapp[]
} }
model Auto { model Auto {
@@ -26,6 +28,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])
@@ -34,28 +37,47 @@ model Auto {
} }
model TipoServicio { model TipoServicio {
id String @id @default(cuid()) id String @id @default(cuid())
nombre String nombre String
duracionHoras Float duracionHoras Float
activo Boolean @default(true) activo Boolean @default(true)
jefeMecanicoId String? // trabajo cargado por un jefe mecánico puntual; null = servicio global (legacy)
turnos Turno[] jefeMecanico JefeMecanico? @relation(fields: [jefeMecanicoId], references: [id])
historial HistorialServicio[] turnos Turno[]
historial HistorialServicio[]
} }
model Turno { model Turno {
id String @id @default(cuid()) id String @id @default(cuid())
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[]
servicios TipoServicio[]
} }
model HistorialServicio { model HistorialServicio {
@@ -100,3 +122,27 @@ enum EstadoTurno {
CANCELADO CANCELADO
COMPLETADO COMPLETADO
} }
// Estado de la conversación del bot de WhatsApp, por número de teléfono.
// El webhook es stateless: acá se guarda en qué paso está y las selecciones parciales.
model ConversacionWhatsapp {
id String @id @default(cuid())
telefono String @unique // wa_id del cliente (E.164, sin '+')
paso String @default("INICIO") // paso de la máquina de estados
datos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.)
clienteId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cliente Cliente? @relation(fields: [clienteId], references: [id])
}
// Ids de mensajes de WhatsApp ya procesados. Meta reintenta el mismo mensaje si el
// webhook tarda o falla, y sin esto un reintento crea el turno dos veces. Va en la
// base (y no en memoria) para que siga valiendo con más de una instancia.
model MensajeWhatsappProcesado {
id String @id // wamid que manda Meta
createdAt DateTime @default(now())
@@index([createdAt])
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+54
View File
@@ -0,0 +1,54 @@
/**
* Chatear con el bot de WhatsApp desde la consola, sin Meta.
*
* Habla directo con `procesarMensaje`, que es lo mismo que invoca el webhook, así
* que ejercita la conversación completa contra la base a la que apunte DATABASE_URL
* (turnos incluidos: lo que reserves acá queda guardado).
*
* DATABASE_URL=... npx tsx scripts/chat-bot.ts [telefono]
*
* Acepta entrada interactiva o por tubería (una línea por mensaje).
* Comandos propios: /salir, /reset (borra la conversación de ese teléfono).
*/
import { createInterface } from "readline";
import { prisma } from "../src/lib/prisma";
import { procesarMensaje } from "../src/lib/whatsapp-bot";
import { normalizarTelefono } from "../src/lib/telefono";
// Normalizado igual que en el bot: si no, /reset no encuentra la conversación.
const telefono = normalizarTelefono(process.argv[2] ?? "5493510000000");
function mostrar(resp: { tipo: string; texto: string; botones?: { titulo: string }[] }[]) {
for (const r of resp) {
console.log("\n🤖 " + r.texto.replace(/\n/g, "\n "));
if (r.tipo === "botones" && r.botones)
console.log(" [ " + r.botones.map((b) => b.titulo).join(" ] [ ") + " ]");
}
}
async function main() {
console.log(`Chateando como ${telefono}. /salir para terminar, /reset para empezar de cero.`);
const rl = createInterface({ input: process.stdin, terminal: false });
for await (const linea of rl) {
const texto = linea.trim();
if (texto === "/salir") break;
if (texto === "/reset") {
await prisma.conversacionWhatsapp.deleteMany({ where: { telefono } });
console.log("\n(conversación borrada)");
continue;
}
console.log(`\n👤 ${texto}`);
mostrar(await procesarMensaje(telefono, texto));
}
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+270
View File
@@ -0,0 +1,270 @@
/**
* Test de la máquina de estados del bot de WhatsApp SIN base de datos ni Meta.
*
* Inyecta un Prisma en memoria en `globalThis.prisma` ANTES de importar el bot,
* así el singleton de `src/lib/prisma.ts` lo toma y nunca abre una conexión real.
* De paso ejercita el scheduling y crearTurno reales (usan el mismo Prisma).
*
* Correr con: npx tsx scripts/test-whatsapp-bot.ts
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
type Row = Record<string, any>;
let _id = 0;
const nid = (p: string) => `${p}${++_id}`;
function cmp(x: any) {
return x instanceof Date ? x.getTime() : x;
}
function match(row: Row, where: Row): boolean {
for (const [k, v] of Object.entries(where)) {
if (v === undefined) continue;
if (k === "AND") {
if (!(v as Row[]).every((w) => match(row, w))) return false;
continue;
}
if (k === "OR") {
if (!(v as Row[]).some((w) => match(row, w))) return false;
continue;
}
const rv = row[k];
if (v !== null && typeof v === "object" && !(v instanceof Date)) {
if ("in" in v && !(v.in as any[]).includes(rv)) return false;
if ("lt" in v && !(cmp(rv) < cmp(v.lt))) return false;
if ("lte" in v && !(cmp(rv) <= cmp(v.lte))) return false;
if ("gt" in v && !(cmp(rv) > cmp(v.gt))) return false;
if ("gte" in v && !(cmp(rv) >= cmp(v.gte))) return false;
} else if (rv !== v) {
return false;
}
}
return true;
}
function pick(row: Row, select?: Row) {
if (!select) return { ...row };
const o: Row = {};
for (const k of Object.keys(select)) if (select[k]) o[k] = row[k];
return o;
}
// ── Stores en memoria ──
const clientes: Row[] = [];
const jefes: Row[] = [];
const servicios: Row[] = [];
const turnos: Row[] = [];
const convs: Row[] = [];
function modelo(rows: Row[], defaults: () => Row = () => ({})) {
return {
async findFirst({ where }: any = {}) {
const r = rows.find((x) => match(x, where || {}));
return r ? { ...r } : null;
},
async findUnique({ where, select }: any = {}) {
const r = rows.find((x) => match(x, where || {}));
return r ? pick(r, select) : null;
},
async findMany({ where, orderBy, select }: any = {}) {
let rs = rows.filter((x) => match(x, where || {}));
if (orderBy) {
const [f, dir] = Object.entries(orderBy)[0] as [string, string];
rs = [...rs].sort((a, b) => (a[f] > b[f] ? 1 : a[f] < b[f] ? -1 : 0) * (dir === "desc" ? -1 : 1));
}
return rs.map((r) => pick(r, select));
},
async count({ where }: any = {}) {
return rows.filter((x) => match(x, where || {})).length;
},
async create({ data }: any) {
const row = { ...defaults(), ...data };
rows.push(row);
return { ...row };
},
async update({ where, data }: any) {
const r = rows.find((x) => match(x, where || {}));
if (!r) throw new Error("update: fila no encontrada");
Object.assign(r, data);
r.updatedAt = new Date();
return { ...r };
},
async upsert({ where, create, update }: any) {
const r = rows.find((x) => match(x, where || {}));
if (r) {
Object.assign(r, update);
return { ...r };
}
const nuevo = { ...defaults(), ...where, ...create };
rows.push(nuevo);
return { ...nuevo };
},
};
}
const fakePrisma: any = {
cliente: modelo(clientes, () => ({ id: nid("c"), telefonoNorm: null, email: null, createdAt: new Date() })),
jefeMecanico: modelo(jefes),
tipoServicio: modelo(servicios),
conversacionWhatsapp: modelo(convs, () => ({
id: nid("conv"),
paso: "INICIO",
datos: {},
clienteId: null,
createdAt: new Date(),
updatedAt: new Date(),
})),
turno: {
async count({ where }: any = {}) {
return turnos.filter((x) => match(x, where || {})).length;
},
async create({ data, include }: any) {
const row: Row = {
id: nid("t"),
estado: "PENDIENTE",
autoId: null,
kilometros: null,
comentario: null,
createdAt: new Date(),
...data,
};
turnos.push(row);
const out: Row = { ...row };
if (include?.auto) out.auto = row.autoId ? { patente: "?", marca: "?", modelo: "?", anio: 0 } : null;
if (include?.tipoServicio) out.tipoServicio = servicios.find((s) => s.id === row.tipoServicioId) ?? null;
if (include?.jefeMecanico) {
const j = jefes.find((x) => x.id === row.jefeMecanicoId);
out.jefeMecanico = j ? { id: j.id, nombre: j.nombre } : null;
}
return out;
},
},
// `crearTurno` verifica y crea dentro de una transacción con un lock por jefe.
// Acá corre todo sobre el mismo cliente falso, que ya es secuencial.
async $transaction(fn: any) {
return fn(fakePrisma);
},
async $executeRaw() {
return 0;
},
};
(globalThis as any).prisma = fakePrisma;
// ── Seed ──
jefes.push({ id: "j1", nombre: "Carlos", email: "j1@t.com", activo: true, slotsParalelos: 1 });
jefes.push({ id: "j2", nombre: "Diego", email: "j2@t.com", activo: true, slotsParalelos: 1 });
servicios.push({ id: "s1", nombre: "Cambio de aceite", duracionHoras: 1, activo: true, jefeMecanicoId: "j1" });
servicios.push({ id: "s2", nombre: "Alineacion", duracionHoras: 2, activo: true, jefeMecanicoId: "j1" });
// Trabajo global del taller (lo carga el admin): lo ofrece cualquier jefe.
servicios.push({ id: "s3", nombre: "Revision general", duracionHoras: 1, activo: true, jefeMecanicoId: null });
clientes.push({
id: "c1",
nombre: "Juan",
apellido: "Perez",
email: "juan@mail.com",
telefono: "5493510000001",
telefonoNorm: "5493510000001",
createdAt: new Date(),
});
// ── Runner de asserts ──
let fallos = 0;
function ok(cond: boolean, msg: string) {
console.log(`${cond ? " ✅" : " ❌"} ${msg}`);
if (!cond) fallos++;
}
const flat = (resp: any[]) =>
resp
.map((x) => (x.tipo === "botones" ? `${x.texto} [${x.botones.map((b: any) => b.titulo).join(",")}]` : x.texto))
.join(" | ");
const hayBotones = (resp: any[]) => resp.some((x) => x.tipo === "botones");
const paso = (tel: string) => convs.find((c) => c.telefono === tel)?.paso;
async function main() {
const { procesarMensaje } = await import("../src/lib/whatsapp-bot");
const env = (tel: string, txt: string) => procesarMensaje(tel, txt);
console.log("\n── Escenario A: cliente NUEVO, alta + reserva completa ──");
const A = "5493519999999";
let r = await env(A, "hola");
ok(/asistente/i.test(flat(r)) && /nombre/i.test(flat(r)), "bienvenida + pide nombre");
ok(paso(A) === "ALTA_NOMBRE", "paso=ALTA_NOMBRE");
r = await env(A, "Pedro");
ok(/apellido/i.test(flat(r)), "pide apellido");
r = await env(A, "Gomez");
ok(/email/i.test(flat(r)), "pide email");
r = await env(A, "pedro@mail.com");
ok(/registrado/i.test(flat(r)) && hayBotones(r), "registrado + botones de mecánico");
ok(paso(A) === "ELEGIR_MECANICO", "paso=ELEGIR_MECANICO");
ok(!!clientes.find((c) => c.telefonoNorm === A), "cliente creado con telefonoNorm");
r = await env(A, "j1");
ok(/Cambio de aceite/i.test(flat(r)), "muestra trabajos del jefe elegido");
ok(/Revision general/i.test(flat(r)), "incluye los trabajos globales del taller");
ok(paso(A) === "ELEGIR_TRABAJO", "paso=ELEGIR_TRABAJO");
r = await env(A, "a");
ok(/Horarios/i.test(flat(r)), "muestra horarios");
ok(paso(A) === "ELEGIR_HORARIO", "paso=ELEGIR_HORARIO");
r = await env(A, "a");
ok(/Confirmás/i.test(flat(r)), "pide confirmación");
ok(paso(A) === "CONFIRMAR", "paso=CONFIRMAR");
r = await env(A, "sí");
ok(/confirmado/i.test(flat(r)), "turno confirmado");
ok(paso(A) === "INICIO", "vuelve a INICIO tras reservar");
const nuevoCliente = clientes.find((c) => c.telefonoNorm === A)!;
const t = turnos.find((x) => x.clienteId === nuevoCliente.id);
ok(!!t, "se creó el turno del cliente");
ok(t?.autoId === null && t?.kilometros === null, "turno sin auto/km (los carga el jefe)");
console.log("\n── Escenario B: cliente EXISTENTE, verificación por email ──");
const B = "5493510000001";
r = await env(B, "hola");
ok(/email/i.test(flat(r)), "pide el email registrado");
ok(paso(B) === "VERIFICAR_EMAIL", "paso=VERIFICAR_EMAIL");
r = await env(B, "nope@x.com");
ok(/no coincide/i.test(flat(r)), "rechaza email incorrecto");
ok(paso(B) === "VERIFICAR_EMAIL", "sigue en VERIFICAR_EMAIL");
r = await env(B, "juan@mail.com");
ok(hayBotones(r), "email correcto → botones de mecánico");
ok(paso(B) === "ELEGIR_MECANICO", "paso=ELEGIR_MECANICO");
console.log("\n── Escenario C: opción inválida + cancelar ──");
r = await env(B, "zzz");
ok(/no entendí/i.test(flat(r)), "opción inválida → 'no entendí'");
ok(paso(B) === "ELEGIR_MECANICO", "no avanza con opción inválida");
r = await env(B, "cancelar");
ok(/cancel/i.test(flat(r)), "cancelar resetea");
ok(paso(B) === "INICIO", "paso=INICIO tras cancelar");
console.log("\n── Escenario D: trabajos por jefe ──");
const D = "5493518888888";
await env(D, "hola");
await env(D, "Ana");
await env(D, "Lopez");
r = await env(D, "ana@mail.com");
ok(hayBotones(r), "alta rápida hasta elegir mecánico");
r = await env(D, "j2");
ok(/Revision general/i.test(flat(r)), "un jefe sin trabajos propios igual ofrece los globales");
ok(!/Cambio de aceite/i.test(flat(r)), "NO ofrece los trabajos propios de otro jefe");
const { crearTurno } = await import("../src/lib/turnos");
const cruzado = await crearTurno({
clienteId: "c1",
jefeMecanicoId: "j2",
tipoServicioId: "s1", // trabajo propio de j1
fechaInicio: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
ok(!cruzado.ok && cruzado.status === 400, "crearTurno rechaza el trabajo de otro jefe (400)");
console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`);
process.exit(fallos === 0 ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+181
View File
@@ -0,0 +1,181 @@
/**
* Test de la capa que habla con Meta (F0) SIN base de datos ni credenciales reales.
*
* Cubre las dos direcciones de `src/lib/whatsapp.ts`:
* - salida: RespuestaBot → payload de la Graph API (con `fetch` stubbeado)
* - entrada: payload del webhook → mensajes/texto
* - firma X-Hub-Signature-256
*
* Correr con: npx tsx scripts/test-whatsapp-envio.ts
*/
import crypto from "crypto";
process.env.WHATSAPP_TOKEN = "token-de-prueba";
process.env.WHATSAPP_PHONE_NUMBER_ID = "111222333";
process.env.WHATSAPP_APP_SECRET = "secreto-de-prueba";
import {
enviarRespuestas,
firmaValida,
mensajesDelPayload,
textoDeMensaje,
whatsappConfigurado,
} from "../src/lib/whatsapp";
let fallos = 0;
function ok(cond: boolean, msg: string) {
console.log(` ${cond ? "✅" : "❌"} ${msg}`);
if (!cond) fallos++;
}
// ── Stub de fetch: guarda lo que se le manda a Meta en vez de salir a internet ──
type Llamada = { url: string; auth: string; body: any };
let llamadas: Llamada[] = [];
// Códigos a devolver, en orden. Si se vacía, responde 200.
let respuestas: number[] = [];
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>;
llamadas.push({
url: String(url),
auth: headers["Authorization"] ?? "",
body: JSON.parse(String(init?.body ?? "{}")),
});
const status = respuestas.shift() ?? 200;
return { ok: status < 400, status, text: async () => "detalle" } as Response;
}) as typeof fetch;
async function main() {
console.log("\n── Config ──");
ok(whatsappConfigurado(), "detecta las credenciales presentes");
console.log("\n── Salida: texto ──");
llamadas = [];
await enviarRespuestas("5493511234567", [{ tipo: "texto", texto: "Hola *mundo*" }]);
ok(llamadas.length === 1, "manda un solo request");
ok(llamadas[0].url.includes("/111222333/messages"), "pega al phone_number_id configurado");
ok(llamadas[0].auth === "Bearer token-de-prueba", "manda el bearer token");
ok(llamadas[0].body.messaging_product === "whatsapp", "incluye messaging_product");
ok(llamadas[0].body.type === "text", "type=text");
ok(llamadas[0].body.to === "5493511234567", "va al teléfono correcto");
ok(llamadas[0].body.text.body === "Hola *mundo*", "respeta el markup de WhatsApp");
console.log("\n── Salida: botones ──");
llamadas = [];
await enviarRespuestas("549351", [
{
tipo: "botones",
texto: "¿Con qué mecánico?",
botones: [
{ id: "j1", titulo: "Carlos" },
{ id: "j2", titulo: "Un nombre larguísimo que no entra" },
{ id: "j3", titulo: "Ana" },
{ id: "j4", titulo: "Cuarto" },
],
},
]);
const inter = llamadas[0].body.interactive;
ok(llamadas[0].body.type === "interactive", "type=interactive");
ok(inter.type === "button", "interactive.type=button");
ok(inter.body.text === "¿Con qué mecánico?", "el texto va en body.text");
ok(inter.action.buttons.length === 3, "recorta a los 3 botones que permite Meta");
ok(inter.action.buttons[1].reply.title.length <= 20, "trunca títulos a 20 caracteres");
ok(inter.action.buttons[0].reply.id === "j1", "conserva el id de la opción");
ok(inter.action.buttons[0].type === "reply", "los botones son de tipo reply");
console.log("\n── Salida: casos borde ──");
llamadas = [];
await enviarRespuestas("549351", [{ tipo: "botones", texto: "sin opciones", botones: [] }]);
ok(llamadas[0].body.type === "text", "sin botones cae a mensaje de texto");
llamadas = [];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "x".repeat(5000) }]);
ok(llamadas[0].body.text.body.length === 4096, "trunca el texto al máximo de Meta");
llamadas = [];
await enviarRespuestas("549351", [
{ tipo: "texto", texto: "uno" },
{ tipo: "texto", texto: "dos" },
]);
ok(llamadas.length === 2, "manda las respuestas múltiples en orden");
ok(llamadas[0].body.text.body === "uno" && llamadas[1].body.text.body === "dos", "respeta el orden");
console.log("\n── Salida: reintentos ──");
llamadas = [];
respuestas = [429];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "con rate limit" }]);
ok(llamadas.length === 2, "reintenta ante un 429 y sale bien en el segundo intento");
llamadas = [];
respuestas = [500, 503];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "con 5xx" }]);
ok(llamadas.length === 3, "reintenta ante 5xx hasta que uno sale bien");
llamadas = [];
respuestas = [500, 500, 500];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "siempre 5xx" }]);
ok(llamadas.length === 3, "no reintenta más de 3 veces");
llamadas = [];
respuestas = [400];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "payload malo" }]);
ok(llamadas.length === 1, "un 400 no se reintenta (es error nuestro)");
llamadas = [];
respuestas = [401];
await enviarRespuestas("549351", [{ tipo: "texto", texto: "token vencido" }]);
ok(llamadas.length === 1, "un 401 no se reintenta");
respuestas = [];
console.log("\n── Firma del webhook ──");
const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" });
const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex");
ok(firmaValida(cuerpo, firma), "acepta la firma correcta");
ok(!firmaValida(cuerpo + " ", firma), "rechaza si el cuerpo fue alterado");
ok(!firmaValida(cuerpo, "sha256=" + "0".repeat(64)), "rechaza una firma inválida");
ok(!firmaValida(cuerpo, null), "rechaza si no viene el header");
ok(!firmaValida(cuerpo, firma.slice("sha256=".length)), "rechaza sin el prefijo sha256=");
console.log("\n── Entrada: payload del webhook ──");
const payload = {
entry: [
{
changes: [
{
value: {
messages: [
{ from: "549351", id: "wamid.1", type: "text", text: { body: "hola" } },
{
from: "549351",
id: "wamid.2",
type: "interactive",
interactive: { button_reply: { id: "jefe-abc", title: "Carlos" } },
},
{ from: "549351", id: "wamid.3", type: "audio", audio: { id: "x" } },
],
},
},
],
},
],
};
const msgs = mensajesDelPayload(payload);
ok(msgs.length === 3, "aplana los 3 mensajes del payload");
ok(textoDeMensaje(msgs[0]) === "hola", "extrae el texto plano");
ok(textoDeMensaje(msgs[1]) === "jefe-abc", "de un botón extrae el id (lo que espera el bot)");
ok(textoDeMensaje(msgs[2]) === null, "un audio devuelve null → el bot no lo procesa");
ok(mensajesDelPayload({}).length === 0, "payload vacío no rompe");
ok(
mensajesDelPayload({ entry: [{ changes: [{ value: {} }] }] }).length === 0,
"un evento de status (sin messages) no genera mensajes"
);
console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`);
process.exit(fallos === 0 ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+2 -2
View File
@@ -40,12 +40,12 @@ export default function ClientesPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<h1 className="text-2xl font-bold text-gray-800">Clientes</h1> <h1 className="text-2xl font-bold text-white">Clientes</h1>
<Card title="Buscar cliente"> <Card title="Buscar cliente">
<form onSubmit={buscar} className="flex gap-2"> <form onSubmit={buscar} className="flex gap-2">
<Input <Input
placeholder="Nombre, apellido o email..." placeholder="Nombre, apellido o patente..."
value={busqueda} value={busqueda}
onChange={(e) => setBusqueda(e.target.value)} onChange={(e) => setBusqueda(e.target.value)}
className="flex-1" className="flex-1"
+49 -7
View File
@@ -18,6 +18,8 @@ export default function AdminDashboard() {
const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10)); const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10));
const [turnos, setTurnos] = useState<Turno[]>([]); const [turnos, setTurnos] = useState<Turno[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [completando, setCompletando] = useState<Set<string>>(new Set());
const [comentarios, setComentarios] = useState<Record<string, string>>({});
useEffect(() => { cargar(); }, [fecha]); useEffect(() => { cargar(); }, [fecha]);
@@ -29,15 +31,26 @@ export default function AdminDashboard() {
setLoading(false); setLoading(false);
} }
async function cambiarEstado(id: string, estado: string) { async function cambiarEstado(id: string, estado: string, comentario?: string) {
await fetch(`/api/admin/turnos/${id}`, { await fetch(`/api/admin/turnos/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado }), body: JSON.stringify({ estado, comentario }),
}); });
setCompletando((prev) => { const s = new Set(prev); s.delete(id); return s; });
setComentarios((prev) => { const c = { ...prev }; delete c[id]; return c; });
cargar(); cargar();
} }
function iniciarCompletado(id: string) {
setCompletando((prev) => new Set(prev).add(id));
}
function cancelarCompletado(id: string) {
setCompletando((prev) => { const s = new Set(prev); s.delete(id); return s; });
setComentarios((prev) => { const c = { ...prev }; delete c[id]; return c; });
}
function formatHora(iso: string) { function formatHora(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" }); return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
} }
@@ -45,17 +58,17 @@ export default function AdminDashboard() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between flex-wrap gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-gray-800">Turnos del día</h1> <h1 className="text-xl sm:text-2xl font-bold text-white">Turnos del día</h1>
<input <input
type="date" type="date"
value={fecha} value={fecha}
onChange={(e) => setFecha(e.target.value)} onChange={(e) => setFecha(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500" className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 w-full sm:w-auto"
/> />
</div> </div>
{loading ? ( {loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div> <div className="text-center py-10 text-gray-200">Cargando...</div>
) : turnos.length === 0 ? ( ) : turnos.length === 0 ? (
<Card> <Card>
<p className="text-center text-gray-400 py-4">No hay turnos para esta fecha.</p> <p className="text-center text-gray-400 py-4">No hay turnos para esta fecha.</p>
@@ -96,11 +109,40 @@ export default function AdminDashboard() {
</Button> </Button>
</div> </div>
)} )}
{t.estado === "CONFIRMADO" && (
<Button onClick={() => cambiarEstado(t.id, "COMPLETADO")} variant="secondary" className="text-sm"> {t.estado === "CONFIRMADO" && !completando.has(t.id) && (
<Button onClick={() => iniciarCompletado(t.id)} variant="secondary" className="text-sm">
✓ Marcar completado ✓ Marcar completado
</Button> </Button>
)} )}
{t.estado === "CONFIRMADO" && completando.has(t.id) && (
<div className="flex flex-col gap-2">
<textarea
value={comentarios[t.id] ?? ""}
onChange={(e) => setComentarios((prev) => ({ ...prev, [t.id]: e.target.value }))}
placeholder="Observaciones (opcional)..."
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
<div className="flex gap-2">
<Button
onClick={() => cambiarEstado(t.id, "COMPLETADO", comentarios[t.id])}
variant="secondary"
className="flex-1 text-sm"
>
✓ Confirmar completado
</Button>
<Button
variant="danger"
onClick={() => cancelarCompletado(t.id)}
className="flex-1 text-sm"
>
Cancelar
</Button>
</div>
</div>
)}
</div> </div>
</Card> </Card>
))} ))}
+43 -112
View File
@@ -2,19 +2,19 @@
import { useState } from "react"; import { useState } from "react";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input"; import Badge from "@/components/ui/Badge";
interface Cliente { id: string; nombre: string; apellido: string; email: string; autos: Auto[]; } interface Cliente { id: string; nombre: string; apellido: string; email: string; autos: Auto[]; }
interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; } interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; }
interface Registro { interface Turno {
id: string; id: string;
descripcion: string; fechaInicio: string;
observaciones: string | null; fechaFin: string;
fechaRealizado: string; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
admin: { nombre: string }; kilometros: number;
tipoServicio: { nombre: string } | null; comentario: string | null;
tipoServicio: { nombre: string };
} }
interface TipoServicio { id: string; nombre: string; }
export default function AdminHistorialPage() { export default function AdminHistorialPage() {
const [busqueda, setBusqueda] = useState(""); const [busqueda, setBusqueda] = useState("");
@@ -22,11 +22,7 @@ export default function AdminHistorialPage() {
const [buscando, setBuscando] = useState(false); const [buscando, setBuscando] = useState(false);
const [clienteSel, setClienteSel] = useState<Cliente | null>(null); const [clienteSel, setClienteSel] = useState<Cliente | null>(null);
const [autoSel, setAutoSel] = useState<Auto | null>(null); const [autoSel, setAutoSel] = useState<Auto | null>(null);
const [historial, setHistorial] = useState<Registro[]>([]); const [turnos, setTurnos] = useState<Turno[]>([]);
const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState({ descripcion: "", observaciones: "", tipoServicioId: "", fechaRealizado: new Date().toISOString().slice(0, 10) });
const [guardando, setGuardando] = useState(false);
async function buscar(e: React.FormEvent) { async function buscar(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -41,50 +37,32 @@ export default function AdminHistorialPage() {
async function seleccionarAuto(auto: Auto) { async function seleccionarAuto(auto: Auto) {
setAutoSel(auto); setAutoSel(auto);
const [hRes, sRes] = await Promise.all([ const res = await fetch(`/api/admin/historial?autoId=${auto.id}`);
fetch(`/api/admin/historial?autoId=${auto.id}`), setTurnos(await res.json());
fetch(`/api/admin/servicios`),
]);
const [h, s] = await Promise.all([hRes.json(), sRes.json()]);
setHistorial(h);
setServicios(s.filter((sv: TipoServicio & { activo: boolean }) => sv.activo));
} }
function set(f: string) { function formatFecha(iso: string) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => return new Date(iso).toLocaleDateString("es-AR", {
setForm((v) => ({ ...v, [f]: e.target.value })); weekday: "short", day: "2-digit", month: "short", year: "numeric",
}
async function guardarRegistro(e: React.FormEvent) {
e.preventDefault();
if (!autoSel) return;
setGuardando(true);
const res = await fetch("/api/admin/historial", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ autoId: autoSel.id, ...form, tipoServicioId: form.tipoServicioId || null }),
}); });
if (res.ok) { }
const nuevo = await res.json();
setHistorial((h) => [nuevo, ...h]); 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>
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { useEffect, useState } from "react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
interface Jefe {
id: string;
nombre: string;
email: string;
slotsParalelos: number;
activo: boolean;
}
interface FormState {
nombre: string;
email: string;
password: string;
slotsParalelos: string;
}
const FORM_VACIO: FormState = { nombre: "", email: "", password: "", slotsParalelos: "1" };
export default function JefesPage() {
const [jefes, setJefes] = useState<Jefe[]>([]);
const [loading, setLoading] = useState(true);
const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState<FormState>(FORM_VACIO);
const [guardando, setGuardando] = useState(false);
const [editando, setEditando] = useState<Jefe | null>(null);
const [errorForm, setErrorForm] = useState("");
useEffect(() => { cargar(); }, []);
async function cargar() {
setLoading(true);
const res = await fetch("/api/admin/jefes");
setJefes(await res.json());
setLoading(false);
}
function set(f: keyof FormState) {
return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((v) => ({ ...v, [f]: e.target.value }));
}
async function guardar(e: React.FormEvent) {
e.preventDefault();
setGuardando(true);
setErrorForm("");
const slots = Number(form.slotsParalelos);
if (!Number.isInteger(slots) || slots < 1) {
setErrorForm("Slots en paralelo debe ser un entero >= 1");
setGuardando(false);
return;
}
if (editando) {
const body: Record<string, unknown> = {
nombre: form.nombre,
email: form.email,
slotsParalelos: slots,
};
if (form.password) body.password = form.password;
const res = await fetch(`/api/admin/jefes/${editando.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorForm(data?.error || "Error al guardar");
setGuardando(false);
return;
}
} else {
if (!form.password) {
setErrorForm("Contraseña requerida");
setGuardando(false);
return;
}
const res = await fetch("/api/admin/jefes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nombre: form.nombre,
email: form.email,
password: form.password,
slotsParalelos: slots,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorForm(data?.error || "Error al guardar");
setGuardando(false);
return;
}
}
setGuardando(false);
setMostrarForm(false);
setEditando(null);
setForm(FORM_VACIO);
cargar();
}
async function toggleActivo(j: Jefe) {
await fetch(`/api/admin/jefes/${j.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activo: !j.activo }),
});
cargar();
}
function iniciarEdicion(j: Jefe) {
setEditando(j);
setForm({
nombre: j.nombre,
email: j.email,
password: "",
slotsParalelos: String(j.slotsParalelos),
});
setMostrarForm(true);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Jefes mecánicos</h1>
<Button
onClick={() => { setMostrarForm(true); setEditando(null); setForm(FORM_VACIO); }}
className="text-sm"
>
+ Nuevo jefe
</Button>
</div>
{mostrarForm && (
<Card title={editando ? "Editar jefe" : "Nuevo jefe"}>
<form onSubmit={guardar} className="flex flex-col gap-4">
<Input id="nombre" label="Nombre" placeholder="Juan Pérez" value={form.nombre} onChange={set("nombre")} required />
<Input id="email" type="email" label="Email" placeholder="juan@taller.com" value={form.email} onChange={set("email")} required />
<Input
id="password"
type="password"
label={editando ? "Nueva contraseña (opcional)" : "Contraseña"}
placeholder={editando ? "Dejar vacío para no cambiar" : "Contraseña inicial"}
value={form.password}
onChange={set("password")}
{...(editando ? {} : { required: true })}
/>
<Input
id="slots"
type="number"
label="Turnos en paralelo"
placeholder="1"
step="1"
min="1"
max="20"
value={form.slotsParalelos}
onChange={set("slotsParalelos")}
required
/>
{errorForm && <p className="text-sm text-red-500">{errorForm}</p>}
<div className="flex gap-2 justify-end">
<Button variant="secondary" type="button" onClick={() => { setMostrarForm(false); setEditando(null); }}>Cancelar</Button>
<Button type="submit" loading={guardando}>Guardar</Button>
</div>
</form>
</Card>
)}
{loading ? (
<div className="text-center py-10 text-gray-200">Cargando...</div>
) : (
<div className="flex flex-col gap-3">
{jefes.map((j) => (
<div
key={j.id}
className={`flex items-center justify-between gap-3 bg-white border rounded-xl px-4 py-3 ${!j.activo ? "opacity-50" : ""}`}
>
<div>
<p className={`font-medium ${j.activo ? "text-gray-800" : "text-gray-400 line-through"}`}>
{j.nombre}
</p>
<p className="text-sm text-gray-500">
{j.email} · {j.slotsParalelos} {j.slotsParalelos === 1 ? "turno paralelo" : "turnos paralelos"}
</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => iniciarEdicion(j)} className="text-sm px-2 py-1">
✏️
</Button>
<Button
variant={j.activo ? "secondary" : "ghost"}
onClick={() => toggleActivo(j)}
className="text-sm px-3 py-1"
>
{j.activo ? "Desactivar" : "Activar"}
</Button>
</div>
</div>
))}
{jefes.length === 0 && (
<p className="text-center text-gray-200 py-4 text-sm">No hay jefes mecánicos cargados.</p>
)}
</div>
)}
</div>
);
}
+12 -12
View File
@@ -7,6 +7,7 @@ const navItems = [
{ href: "/admin/historial", label: "Historial", icon: "📋" }, { href: "/admin/historial", label: "Historial", icon: "📋" },
{ href: "/admin/clientes", label: "Clientes", icon: "👥" }, { href: "/admin/clientes", label: "Clientes", icon: "👥" },
{ href: "/admin/servicios", label: "Servicios", icon: "⚙️" }, { href: "/admin/servicios", label: "Servicios", icon: "⚙️" },
{ href: "/admin/jefes", label: "Jefes", icon: "🔧" },
]; ];
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
@@ -21,18 +22,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (pathname === "/admin") return <>{children}</>; if (pathname === "/admin") return <>{children}</>;
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col"> <div className="min-h-screen flex flex-col">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<span className="font-bold text-blue-600 text-lg">🔧 Taller — Admin</span>
<button onClick={cerrarSesion} className="text-sm text-gray-500 hover:text-red-600 transition">
Cerrar sesión
</button>
</div>
</header>
<div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col md:flex-row gap-6 py-6"> <div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col md:flex-row gap-6 py-6">
<nav className="md:w-48 shrink-0"> <nav className="md:w-48 shrink-0 bg-black/50 backdrop-blur-sm md:bg-transparent rounded-xl md:rounded-none p-2 md:p-0">
<ul className="flex md:flex-col gap-2 flex-wrap"> <ul className="flex md:flex-col gap-2 flex-wrap">
{navItems.map((item) => ( {navItems.map((item) => (
<li key={item.href}> <li key={item.href}>
@@ -41,7 +33,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition ${ className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition ${
pathname.startsWith(item.href) pathname.startsWith(item.href)
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100" : "text-gray-100 hover:bg-white/20"
}`} }`}
> >
<span>{item.icon}</span> <span>{item.icon}</span>
@@ -49,6 +41,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</Link> </Link>
</li> </li>
))} ))}
<li className="md:mt-auto">
<button
onClick={cerrarSesion}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-300 hover:text-red-300 hover:bg-white/10 transition w-full"
>
Cerrar sesión
</button>
</li>
</ul> </ul>
</nav> </nav>
+1 -1
View File
@@ -31,7 +31,7 @@ export default function AdminLoginPage() {
} }
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50"> <div className="flex flex-col items-center justify-center min-h-screen">
<Card className="w-full max-w-sm mx-4"> <Card className="w-full max-w-sm mx-4">
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="text-4xl mb-2">🔧</div> <div className="text-4xl mb-2">🔧</div>
+2 -2
View File
@@ -74,7 +74,7 @@ export default function ServiciosPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-800">Tipos de servicio</h1> <h1 className="text-2xl font-bold text-white">Tipos de servicio</h1>
<Button <Button
onClick={() => { setMostrarForm(true); setEditando(null); setForm({ nombre: "", duracionHoras: "" }); }} onClick={() => { setMostrarForm(true); setEditando(null); setForm({ nombre: "", duracionHoras: "" }); }}
className="text-sm" className="text-sm"
@@ -98,7 +98,7 @@ export default function ServiciosPage() {
)} )}
{loading ? ( {loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div> <div className="text-center py-10 text-gray-200">Cargando...</div>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{servicios.map((s) => ( {servicios.map((s) => (
+2 -2
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sesion = await verificarSesion("admin_session"); const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
@@ -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,
+6 -6
View File
@@ -3,24 +3,24 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sesion = await verificarSesion("admin_session"); const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
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) {
const sesion = await verificarSesion("admin_session"); const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, descripcion, observaciones, fechaRealizado } = await req.json(); const { autoId, tipoServicioId, descripcion, observaciones, fechaRealizado } = await req.json();
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import bcrypt from "bcryptjs";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
const data = await req.json();
const update: {
nombre?: string;
email?: string;
slotsParalelos?: number;
activo?: boolean;
passwordHash?: string;
} = {};
if (data.nombre !== undefined) update.nombre = data.nombre;
if (data.email !== undefined) update.email = String(data.email).toLowerCase().trim();
if (data.slotsParalelos !== undefined) {
const slots = Number(data.slotsParalelos);
if (!Number.isInteger(slots) || slots < 1)
return NextResponse.json({ error: "slotsParalelos debe ser un entero >= 1" }, { status: 400 });
update.slotsParalelos = slots;
}
if (data.activo !== undefined) update.activo = !!data.activo;
if (data.password) {
update.passwordHash = await bcrypt.hash(data.password, 10);
}
const jefe = await prisma.jefeMecanico.update({
where: { id },
data: update,
select: { id: true, nombre: true, email: true, slotsParalelos: true, activo: true, createdAt: true },
});
return NextResponse.json(jefe);
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import bcrypt from "bcryptjs";
export async function GET() {
const sesion = await verificarSesion("admin_session", "admin");
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", "admin");
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 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
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", "admin");
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;
+2 -2
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET() { export async function GET() {
const sesion = await verificarSesion("admin_session"); const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const servicios = await prisma.tipoServicio.findMany({ orderBy: { nombre: "asc" } }); const servicios = await prisma.tipoServicio.findMany({ orderBy: { nombre: "asc" } });
@@ -11,7 +11,7 @@ export async function GET() {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const sesion = await verificarSesion("admin_session"); const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, duracionHoras } = await req.json(); const { nombre, duracionHoras } = await req.json();
+40 -3
View File
@@ -1,19 +1,56 @@
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", "admin");
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();
// Regla: no se puede cerrar (COMPLETADO) un turno sin auto y kilometraje cargados.
// Las reservas por WhatsApp los dejan vacíos; el jefe mecánico debe completarlos antes de cerrar.
if (estado === "COMPLETADO") {
const actual = await prisma.turno.findUnique({
where: { id },
select: { autoId: true, kilometros: true },
});
if (!actual) return NextResponse.json({ error: "Turno no encontrado" }, { status: 404 });
if (!actual.autoId || actual.kilometros == null)
return NextResponse.json(
{ error: "Cargá el auto y el kilometraje antes de cerrar el turno" },
{ status: 400 }
);
}
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" && turno.auto && turno.kilometros != null) {
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);
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sesion = await verificarSesion("admin_session"); const sesion = await verificarSesion("admin_session", "admin");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { crearSesionJefe } from "@/lib/auth-admin";
import bcrypt from "bcryptjs";
export async function POST(req: NextRequest) {
const { email, password } = await req.json();
if (!email || !password)
return NextResponse.json({ error: "Credenciales requeridas" }, { status: 400 });
const jefe = await prisma.jefeMecanico.findUnique({
where: { email: String(email).toLowerCase().trim() },
});
if (!jefe || !jefe.activo)
return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 });
const ok = await bcrypt.compare(password, jefe.passwordHash);
if (!ok) return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 });
const jwt = await crearSesionJefe(jefe.id);
const response = NextResponse.json({ ok: true, jefe: { id: jefe.id, nombre: jefe.nombre } });
response.cookies.set("jefe_session", jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 8,
path: "/",
});
return response;
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set("jefe_session", "", { maxAge: 0, path: "/" });
return response;
}
+4 -3
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET() { export async function GET() {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const autos = await prisma.auto.findMany({ const autos = await prisma.auto.findMany({
@@ -15,10 +15,10 @@ export async function GET() {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session", "cliente");
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,
}, },
}); });
+5 -5
View File
@@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin"; import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
@@ -14,11 +14,11 @@ export async function GET(req: NextRequest) {
const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } }); const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } });
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 }); if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
const historial = await prisma.historialServicio.findMany({ const turnos = await prisma.turno.findMany({
where: { autoId }, where: { autoId },
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } }, include: { tipoServicio: { select: { nombre: true } } },
orderBy: { fechaRealizado: "desc" }, orderBy: { fechaInicio: "desc" },
}); });
return NextResponse.json(historial); return NextResponse.json(turnos);
} }
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET() {
const sesion = await verificarSesion("cliente_session", "cliente");
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);
}
+97
View File
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { aFormatoWhatsapp, aLocalArgentino, esLocalArgentinoValido } from "@/lib/telefono";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Datos de contacto del cliente logueado. El teléfono se devuelve en formato local
// (sin el +54 9) porque el formulario lo muestra como prefijo fijo.
export async function GET() {
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const cliente = await prisma.cliente.findUnique({
where: { id: sesion.sub },
select: { nombre: true, apellido: true, email: true, telefono: true, telefonoNorm: true },
});
if (!cliente) return NextResponse.json({ error: "Cliente no encontrado" }, { status: 404 });
return NextResponse.json({
nombre: cliente.nombre,
apellido: cliente.apellido,
email: cliente.email ?? "",
telefono: aLocalArgentino(cliente.telefonoNorm ?? cliente.telefono ?? ""),
});
}
export async function PATCH(req: NextRequest) {
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, apellido, email, telefono } = await req.json();
if (!nombre?.trim() || !apellido?.trim())
return NextResponse.json({ error: "Nombre y apellido son obligatorios" }, { status: 400 });
if (!email?.trim() || !EMAIL_RE.test(email.trim()))
return NextResponse.json({ error: "El email no es válido" }, { status: 400 });
if (!esLocalArgentinoValido(telefono ?? ""))
return NextResponse.json(
{ error: "El teléfono debe tener 10 dígitos, sin el 0 de la característica ni el 15" },
{ status: 400 }
);
const emailNuevo = email.trim().toLowerCase();
const telefonoNorm = aFormatoWhatsapp(telefono);
// El email es con lo que se entra al sistema y el teléfono es con lo que el bot
// reconoce al cliente: los dos tienen que seguir siendo únicos.
const emailTomado = await prisma.cliente.findFirst({
where: { email: emailNuevo, id: { not: sesion.sub } },
select: { id: true },
});
if (emailTomado)
return NextResponse.json({ error: "Ese email ya está registrado" }, { status: 409 });
const telefonoTomado = await prisma.cliente.findFirst({
where: { telefonoNorm, id: { not: sesion.sub } },
select: { id: true },
});
if (telefonoTomado)
return NextResponse.json({ error: "Ese teléfono ya está registrado" }, { status: 409 });
// Entre la comprobación de arriba y este update otra request puede tomar el mismo
// email o teléfono; los índices únicos lo cortan y acá se traduce a un 409 legible.
let cliente;
try {
cliente = await prisma.cliente.update({
where: { id: sesion.sub },
data: {
nombre: nombre.trim(),
apellido: apellido.trim(),
email: emailNuevo,
telefono: aLocalArgentino(telefonoNorm),
telefonoNorm,
},
select: { nombre: true, apellido: true, email: true, telefonoNorm: true },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002")
return NextResponse.json(
{ error: "Ese email o teléfono ya está registrado" },
{ status: 409 }
);
throw e;
}
return NextResponse.json({
ok: true,
nombre: cliente.nombre,
apellido: cliente.apellido,
email: cliente.email ?? "",
telefono: aLocalArgentino(cliente.telefonoNorm ?? ""),
});
}
+33 -6
View File
@@ -1,8 +1,17 @@
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 { aFormatoWhatsapp, esLocalArgentinoValido, normalizarTelefono } from "@/lib/telefono";
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();
@@ -10,16 +19,34 @@ export async function POST(req: NextRequest) {
if (!email || !nombre || !apellido || !telefono) if (!email || !nombre || !apellido || !telefono)
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 }); return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
if (!esLocalArgentinoValido(telefono))
return NextResponse.json(
{ error: "El teléfono debe tener 10 dígitos, sin el 0 de la característica ni el 15" },
{ status: 400 }
);
const existe = await prisma.cliente.findUnique({ where: { email: email.toLowerCase() } }); const existe = await prisma.cliente.findUnique({ where: { email: email.toLowerCase() } });
if (existe) return NextResponse.json({ error: "El email ya está registrado" }, { status: 409 }); if (existe) return NextResponse.json({ error: "El email ya está registrado" }, { status: 409 });
// `telefonoNorm` queda igual al `wa_id` que manda Meta, que es como lo busca el bot.
const telefonoNorm = aFormatoWhatsapp(telefono);
const yaUsado = await prisma.cliente.findFirst({ where: { telefonoNorm } });
if (yaUsado)
return NextResponse.json({ error: "Ese teléfono ya está registrado" }, { status: 409 });
const cliente = await prisma.cliente.create({ const cliente = await prisma.cliente.create({
data: { email: email.toLowerCase().trim(), nombre, apellido, telefono }, data: {
email: email.toLowerCase().trim(),
nombre,
apellido,
telefono: normalizarTelefono(telefono),
telefonoNorm,
},
}); });
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>
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { whereTrabajosDeJefe } from "@/lib/servicios";
// Trabajos que ofrece un jefe mecánico: los suyos + los globales del taller.
// El flujo del cliente es jefe → trabajo, así que el jefe es obligatorio.
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const jefeMecanicoId = req.nextUrl.searchParams.get("jefeMecanicoId");
if (!jefeMecanicoId)
return NextResponse.json({ error: "jefeMecanicoId requerido" }, { status: 400 });
// Un jefe dado de baja no debe exponer sus trabajos (ni dejar arrancar una reserva).
const jefe = await prisma.jefeMecanico.findUnique({
where: { id: jefeMecanicoId },
select: { activo: true },
});
if (!jefe || !jefe.activo)
return NextResponse.json({ error: "Jefe mecánico no disponible" }, { status: 404 });
const servicios = await prisma.tipoServicio.findMany({
where: whereTrabajosDeJefe(jefeMecanicoId),
orderBy: { nombre: "asc" },
});
return NextResponse.json(servicios);
}
+8 -2
View File
@@ -2,20 +2,26 @@ 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 { obtenerSlotsDisponibles } from "@/lib/scheduling"; import { obtenerSlotsDisponibles } from "@/lib/scheduling";
import { trabajoEsDeJefe } from "@/lib/servicios";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
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 });
if (!trabajoEsDeJefe(servicio, jefeMecanicoId))
return NextResponse.json({ error: "Ese trabajo no lo hace este mecánico" }, { status: 400 });
const slots = await obtenerSlotsDisponibles(servicio.duracionHoras); const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500);
return NextResponse.json(slots); return NextResponse.json(slots);
} }
+21 -21
View File
@@ -1,15 +1,15 @@
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 { slotEstaDisponible } from "@/lib/scheduling"; import { crearTurno } from "@/lib/turnos";
export async function GET() { export async function GET() {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session", "cliente");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
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" },
}); });
@@ -17,34 +17,34 @@ export async function GET() {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session", "cliente");
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 },
}); });
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 }); if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } }); const resultado = await crearTurno({
if (!servicio || !servicio.activo) clienteId: sesion.sub,
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 }); jefeMecanicoId,
tipoServicioId,
const inicio = new Date(fechaInicio); fechaInicio: new Date(fechaInicio),
const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000); autoId,
kilometros: km,
const disponible = await slotEstaDisponible(inicio, fin);
if (!disponible)
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
const turno = await prisma.turno.create({
data: { clienteId: sesion.sub, autoId, tipoServicioId, fechaInicio: inicio, fechaFin: fin },
include: { auto: true, tipoServicio: true },
}); });
return NextResponse.json(turno, { status: 201 }); if (!resultado.ok)
return NextResponse.json({ error: resultado.error }, { status: resultado.status });
return NextResponse.json(resultado.turno, { status: 201 });
} }
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
// Edita un trabajo propio del jefe (nombre, duración, activo). Solo si le pertenece.
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
const propio = await prisma.tipoServicio.findFirst({
where: { id, jefeMecanicoId: sesion.sub },
});
if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 });
const data = await req.json();
if (data.duracionHoras !== undefined) {
const dur = Number(data.duracionHoras);
if (!Number.isFinite(dur) || dur <= 0 || dur > 24)
return NextResponse.json(
{ error: "La duración debe ser mayor que 0 y hasta 24 horas" },
{ status: 400 }
);
}
if (data.nombre !== undefined && (typeof data.nombre !== "string" || !data.nombre.trim()))
return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 });
const servicio = await prisma.tipoServicio.update({
where: { id },
data: {
...(data.nombre !== undefined && { nombre: data.nombre.trim() }),
...(data.duracionHoras !== undefined && { duracionHoras: Number(data.duracionHoras) }),
...(data.activo !== undefined && { activo: Boolean(data.activo) }),
},
});
return NextResponse.json(servicio);
}
// Elimina un trabajo propio. Si ya tiene turnos asociados, lo desactiva en vez de borrarlo
// (para no romper la integridad referencial de los turnos históricos).
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
const propio = await prisma.tipoServicio.findFirst({
where: { id, jefeMecanicoId: sesion.sub },
});
if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 });
const enUso = await prisma.turno.count({ where: { tipoServicioId: id } });
if (enUso > 0) {
const servicio = await prisma.tipoServicio.update({
where: { id },
data: { activo: false },
});
return NextResponse.json({ ...servicio, desactivado: true });
}
await prisma.tipoServicio.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
// Lista los trabajos cargados por el jefe logueado (los suyos, no los globales).
export async function GET() {
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const servicios = await prisma.tipoServicio.findMany({
where: { jefeMecanicoId: sesion.sub },
orderBy: { nombre: "asc" },
});
return NextResponse.json(servicios);
}
// Crea un trabajo propio del jefe (nombre + duración en horas).
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, duracionHoras } = await req.json();
const dur = Number(duracionHoras);
if (!nombre || typeof nombre !== "string" || !nombre.trim())
return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 });
if (!Number.isFinite(dur) || dur <= 0 || dur > 24)
return NextResponse.json(
{ error: "La duración debe ser mayor que 0 y hasta 24 horas" },
{ status: 400 }
);
const servicio = await prisma.tipoServicio.create({
data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub },
});
return NextResponse.json(servicio, { status: 201 });
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("jefe_session", "jefe");
if (!sesion)
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);
}
+111
View File
@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { procesarMensaje } from "@/lib/whatsapp-bot";
import {
enviarRespuestas,
firmaValida,
marcarLeido,
mensajesDelPayload,
textoDeMensaje,
type PayloadWebhook,
} from "@/lib/whatsapp";
export const dynamic = "force-dynamic";
/**
* Webhook de WhatsApp Business Cloud API.
* GET → verificación del webhook al configurarlo en el panel de Meta.
* POST → mensajes entrantes: se los pasa a la máquina de conversación y se contesta.
*/
// Meta reintenta el mismo mensaje si tardamos o si algo falla, y sin deduplicar eso
// crea el turno dos veces. Se resuelve insertando el id en la base: el índice único
// hace la operación atómica, así que sirve incluso con varias instancias corriendo.
const DIAS_RETENCION = 7;
let ultimaLimpieza = 0;
async function yaProcesado(id: string): Promise<boolean> {
try {
await prisma.mensajeWhatsappProcesado.create({ data: { id } });
return false;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") return true;
throw e;
}
}
/** Borra los ids viejos como mucho una vez por día. Best-effort: si falla, no importa. */
async function limpiarProcesadosViejos(): Promise<void> {
const unDia = 24 * 60 * 60 * 1000;
if (Date.now() - ultimaLimpieza < unDia) return;
ultimaLimpieza = Date.now();
try {
await prisma.mensajeWhatsappProcesado.deleteMany({
where: { createdAt: { lt: new Date(Date.now() - DIAS_RETENCION * unDia) } },
});
} catch (e) {
console.error("[whatsapp] no se pudieron limpiar los mensajes procesados:", e);
}
}
export async function GET(req: NextRequest) {
const params = req.nextUrl.searchParams;
const modo = params.get("hub.mode");
const token = params.get("hub.verify_token");
const challenge = params.get("hub.challenge");
const esperado = process.env.WHATSAPP_VERIFY_TOKEN;
if (modo === "subscribe" && esperado && token === esperado && challenge)
return new NextResponse(challenge, { status: 200, headers: { "Content-Type": "text/plain" } });
return new NextResponse("Forbidden", { status: 403 });
}
export async function POST(req: NextRequest) {
// La firma se calcula sobre el cuerpo CRUDO: leerlo como texto y recién ahí parsear.
const cuerpoCrudo = await req.text();
if (!firmaValida(cuerpoCrudo, req.headers.get("x-hub-signature-256"))) {
console.warn("[whatsapp] webhook con firma inválida — descartado");
return new NextResponse("Forbidden", { status: 403 });
}
let body: PayloadWebhook;
try {
body = JSON.parse(cuerpoCrudo) as PayloadWebhook;
} catch {
return NextResponse.json({ ok: true });
}
try {
await limpiarProcesadosViejos();
for (const mensaje of mensajesDelPayload(body)) {
const telefono = mensaje.from;
const messageId = mensaje.id;
if (!telefono || !messageId || (await yaProcesado(messageId))) continue;
void marcarLeido(messageId).catch(() => {});
const texto = textoDeMensaje(mensaje);
if (texto === null) {
await enviarRespuestas(telefono, [
{
tipo: "texto",
texto: "Por ahora solo entiendo mensajes de texto. Escribime qué necesitás. 🙂",
},
]);
continue;
}
const respuestas = await procesarMensaje(telefono, texto);
await enviarRespuestas(telefono, respuestas);
}
} catch (e) {
// Nunca devolvemos error a Meta: reintentaría el mismo mensaje en loop.
console.error("[whatsapp] error procesando webhook:", e);
}
return NextResponse.json({ ok: true });
}
+4 -4
View File
@@ -31,7 +31,7 @@ function AuthContent() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg> </svg>
<p className="text-gray-500">Verificando tu acceso...</p> <p className="text-gray-200">Verificando tu acceso...</p>
</div> </div>
); );
} }
@@ -39,9 +39,9 @@ function AuthContent() {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4"> <div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
<div className="text-5xl">❌</div> <div className="text-5xl">❌</div>
<h1 className="text-xl font-bold text-gray-800">Link inválido o expirado</h1> <h1 className="text-xl font-bold text-white">Link inválido o expirado</h1>
<p className="text-gray-500">El link de acceso ya fue usado o expiró. Pedí uno nuevo.</p> <p className="text-gray-200">El link de acceso ya fue usado o expiró. Pedí uno nuevo.</p>
<a href="/cliente" className="text-blue-600 hover:underline font-medium"> <a href="/cliente" className="text-blue-300 hover:underline font-medium">
Volver al inicio Volver al inicio
</a> </a>
</div> </div>
+44 -15
View File
@@ -5,6 +5,8 @@ import Link from "next/link";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";
import Combobox from "@/components/ui/Combobox";
import { MARCAS, getModelos } from "@/lib/marcas-modelos";
interface Auto { interface Auto {
id: string; id: string;
@@ -12,6 +14,7 @@ interface Auto {
marca: string; marca: string;
modelo: string; modelo: string;
anio: number; anio: number;
nroChasis?: string | null;
} }
export default function DashboardPage() { export default function DashboardPage() {
@@ -19,7 +22,8 @@ export default function DashboardPage() {
const [autos, setAutos] = useState<Auto[]>([]); const [autos, setAutos] = useState<Auto[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [mostrarForm, setMostrarForm] = useState(false); const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState({ patente: "", marca: "", modelo: "", anio: "" }); const [form, setForm] = useState({ patente: "", marca: "", modelo: "", anio: "", nroChasis: "" });
const modelosDisponibles = getModelos(form.marca);
const [guardando, setGuardando] = useState(false); const [guardando, setGuardando] = useState(false);
const [errorForm, setErrorForm] = useState(""); const [errorForm, setErrorForm] = useState("");
@@ -51,7 +55,7 @@ export default function DashboardPage() {
setGuardando(false); setGuardando(false);
if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; } if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; }
setMostrarForm(false); setMostrarForm(false);
setForm({ patente: "", marca: "", modelo: "", anio: "" }); setForm({ patente: "", marca: "", modelo: "", anio: "", nroChasis: "" });
cargarAutos(); cargarAutos();
} }
@@ -63,19 +67,24 @@ 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"> <div className="flex items-center gap-4">
Salir <Link href="/cliente/perfil" className="text-sm text-gray-200 hover:text-white transition">
</button> Mis datos
</Link>
<button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
Salir
</button>
</div>
</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 +92,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 +108,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 +117,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">
+50 -41
View File
@@ -2,23 +2,24 @@
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
interface Registro { interface Turno {
id: string; id: string;
descripcion: string; fechaInicio: string;
observaciones: string | null; fechaFin: string;
fechaRealizado: string; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
admin: { nombre: string }; kilometros: number;
tipoServicio: { nombre: string } | null; comentario: string | null;
tipoServicio: { nombre: string };
} }
function HistorialContent() { function HistorialContent() {
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const autoId = params.get("autoId"); const autoId = params.get("autoId");
const [historial, setHistorial] = useState<Registro[]>([]); const [turnos, setTurnos] = useState<Turno[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -29,53 +30,61 @@ function HistorialContent() {
return r; return r;
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { setHistorial(data); setLoading(false); }) .then((data) => { setTurnos(data); setLoading(false); })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, [autoId, router]); }, [autoId, router]);
function formatFecha(iso: string) {
return new Date(iso).toLocaleDateString("es-AR", {
weekday: "long", day: "2-digit", month: "long", year: "numeric",
});
}
function formatHora(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/cliente/dashboard"> <Link href="/cliente/dashboard">
<Button variant="ghost" className="px-2">← Volver</Button> <Button variant="ghost" className="px-2">← Volver</Button>
</Link> </Link>
<h1 className="text-2xl font-bold text-gray-800">Historial del auto</h1> <h1 className="text-xl sm:text-2xl font-bold text-white">Historial del auto</h1>
</div> </div>
{loading ? ( {loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div> <div className="text-center py-10 text-gray-200">Cargando...</div>
) : historial.length === 0 ? ( ) : turnos.length === 0 ? (
<Card> <div className="bg-white border border-gray-200 rounded-2xl p-8 text-center">
<p className="text-center text-gray-400 py-4">Este auto no tiene historial registrado todavía.</p> <p className="text-4xl mb-3">🔧</p>
</Card> <p className="text-gray-500 font-medium">Sin turnos registrados</p>
<p className="text-sm text-gray-400 mt-1">Los turnos aparecerán aquí cuando reservés uno.</p>
</div>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{historial.map((reg) => ( {turnos.map((t) => (
<Card key={reg.id}> <div key={t.id} className="bg-white border border-gray-200 rounded-2xl px-4 py-3 flex flex-col gap-2 shadow-sm">
<div className="flex flex-col gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-start justify-between gap-2 flex-wrap"> <div>
<div> <p className="font-semibold text-gray-800">{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>
); );
+2 -7
View File
@@ -6,13 +6,8 @@ export const metadata: Metadata = {
export default function ClienteLayout({ children }: { children: React.ReactNode }) { export default function ClienteLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10"> <main className="max-w-2xl mx-auto px-4 py-8">{children}</main>
<div className="max-w-2xl mx-auto px-4 h-14 flex items-center justify-between">
<span className="font-bold text-blue-600 text-lg">🔧 Taller</span>
</div>
</header>
<main className="max-w-2xl mx-auto px-4 py-6">{children}</main>
</div> </div>
); );
} }
+3 -3
View File
@@ -33,12 +33,12 @@ export default function ClienteLoginPage() {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4"> <div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
<div className="text-5xl">📧</div> <div className="text-5xl">📧</div>
<h1 className="text-2xl font-bold text-gray-800">Revisá tu email</h1> <h1 className="text-2xl font-bold text-white">Revisá tu email</h1>
<p className="text-gray-500 max-w-sm"> <p className="text-gray-200 max-w-sm">
Te enviamos un link de acceso a <strong>{email}</strong>. El link expira en 15 minutos. Te enviamos un link de acceso a <strong>{email}</strong>. El link expira en 15 minutos.
</p> </p>
<button <button
className="text-sm text-blue-600 hover:underline mt-2" className="text-sm text-blue-300 hover:underline mt-2"
onClick={() => { setEnviado(false); setEmail(""); }} onClick={() => { setEnviado(false); setEmail(""); }}
> >
Usar otro email Usar otro email
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card";
import Input from "@/components/ui/Input";
import { esLocalArgentinoValido } from "@/lib/telefono";
interface Perfil {
nombre: string;
apellido: string;
email: string;
telefono: string;
}
const VACIO: Perfil = { nombre: "", apellido: "", email: "", telefono: "" };
export default function PerfilPage() {
const router = useRouter();
const [perfil, setPerfil] = useState<Perfil>(VACIO);
const [form, setForm] = useState<Perfil>(VACIO);
const [loading, setLoading] = useState(true);
const [editando, setEditando] = useState(false);
const [guardando, setGuardando] = useState(false);
const [error, setError] = useState("");
const [telefonoError, setTelefonoError] = useState("");
const [guardado, setGuardado] = useState(false);
useEffect(() => {
fetch("/api/cliente/perfil")
.then((r) => {
if (r.status === 401) { router.push("/cliente"); return null; }
if (!r.ok) throw new Error("respuesta con error");
return r.json();
})
.then((data) => {
if (!data) return;
setPerfil(data);
setForm(data);
setLoading(false);
})
// Sin esto, si el pedido falla la pantalla se queda en "Cargando..." para siempre.
.catch(() => {
setError("No pudimos cargar tus datos. Probá recargar la página.");
setLoading(false);
});
}, [router]);
function set(campo: keyof Perfil) {
return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [campo]: e.target.value }));
}
function handleTelefono(e: React.ChangeEvent<HTMLInputElement>) {
const soloDigitos = e.target.value.replace(/\D/g, "").slice(0, 10);
setForm((f) => ({ ...f, telefono: soloDigitos }));
if (soloDigitos.startsWith("0")) setTelefonoError("Sin el 0 de la característica");
else if (soloDigitos.startsWith("15")) setTelefonoError("Sin el 15, poné la característica");
else setTelefonoError("");
}
function cancelar() {
setForm(perfil);
setEditando(false);
setError("");
setTelefonoError("");
}
async function guardar(e: React.FormEvent) {
e.preventDefault();
if (!esLocalArgentinoValido(form.telefono)) {
setTelefonoError("Son 10 dígitos: característica sin el 0 y número sin el 15");
return;
}
setGuardando(true);
setError("");
const res = await fetch("/api/cliente/perfil", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
// Si la sesión venció mientras editaba, mandarlo a entrar de nuevo en vez de
// mostrarle un error suelto que no explica nada.
if (res.status === 401) { router.push("/cliente"); return; }
const data = await res.json();
setGuardando(false);
if (!res.ok) { setError(data.error || "No se pudieron guardar los cambios"); return; }
setPerfil(data);
setForm(data);
setEditando(false);
setGuardado(true);
setTimeout(() => setGuardado(false), 4000);
}
if (loading) return <div className="text-center py-10 text-gray-200">Cargando...</div>;
const emailCambio = editando && form.email.trim().toLowerCase() !== perfil.email.toLowerCase();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
<h1 className="text-xl sm:text-2xl font-bold text-white">Mis datos</h1>
</div>
<Card>
{!editando ? (
<div className="flex flex-col gap-4">
<Dato etiqueta="Nombre" valor={`${perfil.nombre} ${perfil.apellido}`} />
<Dato etiqueta="Email" valor={perfil.email || "—"} />
<Dato etiqueta="Teléfono" valor={perfil.telefono ? `+54 9 ${perfil.telefono}` : "—"} />
{guardado && <p className="text-sm text-green-600">✅ Datos actualizados</p>}
<Button onClick={() => setEditando(true)} className="self-start">
Modificar datos
</Button>
</div>
) : (
<form onSubmit={guardar} className="flex flex-col gap-4">
<div className="grid sm:grid-cols-2 gap-4">
<Input id="nombre" label="Nombre" value={form.nombre} onChange={set("nombre")} required />
<Input id="apellido" label="Apellido" value={form.apellido} onChange={set("apellido")} required />
</div>
<Input
id="email"
type="email"
label="Email"
value={form.email}
onChange={set("email")}
ayuda={emailCambio ? "Ojo: con este email vas a entrar de ahora en más." : undefined}
required
/>
<Input
id="telefono"
type="tel"
inputMode="numeric"
label="Teléfono (celular)"
prefijo="+54 9"
placeholder="1141984844"
maxLength={10}
ayuda="Es el celular con el que sacás turnos por WhatsApp."
error={telefonoError}
value={form.telefono}
onChange={handleTelefono}
required
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2 justify-end">
<Button variant="secondary" type="button" onClick={cancelar}>Cancelar</Button>
<Button type="submit" loading={guardando}>Guardar cambios</Button>
</div>
</form>
)}
</Card>
</div>
);
}
function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<div>
<p className="text-sm text-gray-500">{etiqueta}</p>
<p className="font-medium text-gray-800">{valor}</p>
</div>
);
}
+38 -1
View File
@@ -5,19 +5,42 @@ import Link from "next/link";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import { esLocalArgentinoValido } from "@/lib/telefono";
export default function RegistroPage() { export default function RegistroPage() {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState({ email: "", nombre: "", apellido: "", telefono: "" }); const [form, setForm] = useState({ email: "", nombre: "", apellido: "", telefono: "" });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [telefonoError, setTelefonoError] = useState("");
function set(field: string) { function set(field: string) {
return (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [field]: e.target.value })); return (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [field]: e.target.value }));
} }
// El teléfono se guarda como lo espera WhatsApp: solo característica + abonado,
// sin el 0 ni el 15 (el +54 9 lo pone el prefijo fijo del campo).
function handleTelefono(e: React.ChangeEvent<HTMLInputElement>) {
const soloDigitos = e.target.value.replace(/\D/g, "").slice(0, 10);
setForm((f) => ({ ...f, telefono: soloDigitos }));
// Mientras tipea sólo se avisa lo que ya es un error seguro; lo de "faltan
// dígitos" espera a que salga del campo, para no retarlo por la mitad.
if (soloDigitos.startsWith("0")) setTelefonoError("Sin el 0 de la característica");
else if (soloDigitos.startsWith("15")) setTelefonoError("Sin el 15, poné la característica");
else setTelefonoError("");
}
function handleTelefonoBlur() {
if (form.telefono && !esLocalArgentinoValido(form.telefono))
setTelefonoError("Son 10 dígitos: característica sin el 0 y número sin el 15");
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!esLocalArgentinoValido(form.telefono)) {
setTelefonoError("Revisá el número: son 10 dígitos, sin el 0 y sin el 15");
return;
}
setLoading(true); setLoading(true);
setError(""); setError("");
const res = await fetch("/api/cliente/registro", { const res = await fetch("/api/cliente/registro", {
@@ -47,7 +70,21 @@ export default function RegistroPage() {
<Input id="nombre" label="Nombre" placeholder="Juan" value={form.nombre} onChange={set("nombre")} required /> <Input id="nombre" label="Nombre" placeholder="Juan" value={form.nombre} onChange={set("nombre")} required />
<Input id="apellido" label="Apellido" placeholder="Pérez" value={form.apellido} onChange={set("apellido")} required /> <Input id="apellido" label="Apellido" placeholder="Pérez" value={form.apellido} onChange={set("apellido")} required />
<Input id="email" type="email" label="Email" placeholder="juan@email.com" value={form.email} onChange={set("email")} required /> <Input id="email" type="email" label="Email" placeholder="juan@email.com" value={form.email} onChange={set("email")} required />
<Input id="telefono" type="tel" label="Teléfono" placeholder="11 1234-5678" value={form.telefono} onChange={set("telefono")} required /> <Input
id="telefono"
type="tel"
inputMode="numeric"
label="Teléfono (celular)"
prefijo="+54 9"
placeholder="1141984844"
maxLength={10}
ayuda="Característica sin el 0 y número sin el 15. Es el celular con el que vas a poder sacar turnos por WhatsApp."
error={telefonoError}
value={form.telefono}
onChange={handleTelefono}
onBlur={handleTelefonoBlur}
required
/>
{error && <p className="text-sm text-red-500 text-center">{error}</p>} {error && <p className="text-sm text-red-500 text-center">{error}</p>}
<Button type="submit" loading={loading} className="w-full"> <Button type="submit" loading={loading} className="w-full">
Crear cuenta Crear cuenta
+371 -105
View File
@@ -1,5 +1,5 @@
"use client"; "use client";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
@@ -7,95 +7,236 @@ 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"];
// El cliente elige primero el mecánico y después el trabajo, porque cada jefe
// carga sus propios trabajos (además de los globales del taller).
type Step = "jefe" | "servicio" | "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();
const params = useSearchParams(); const params = useSearchParams();
const autoId = params.get("autoId"); const autoId = params.get("autoId");
const [step, setStep] = useState<Step>("servicio"); const [step, setStep] = useState<Step>("jefe");
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 [loadingServicios, setLoadingServicios] = useState(false);
const [jefes, setJefes] = useState<Jefe[]>([]);
const [jefeSel, setJefeSel] = useState<Jefe | null>(null);
const [loadingJefes, setLoadingJefes] = useState(true);
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/jefes")
.then((r) => r.json()) .then(r => r.json())
.then((data) => setServicios(data.filter((s: TipoServicio & { activo: boolean }) => s.activo))); .then((data) => {
setJefes(Array.isArray(data) ? data : []);
setLoadingJefes(false);
});
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 seleccionarJefe(j: Jefe) {
setJefeSel(j);
setServicioSel(null);
setServicios([]);
setSlots([]);
setDiaSeleccionado(null);
setSlotSel(null);
setLoadingServicios(true);
setStep("servicio");
const res = await fetch(`/api/cliente/servicios?jefeMecanicoId=${j.id}`);
const data = await res.json();
setServicios(Array.isArray(data) ? data : []);
setLoadingServicios(false);
}
async function seleccionarServicio(s: TipoServicio) { async function seleccionarServicio(s: TipoServicio) {
if (!jefeSel) return;
setServicioSel(s); setServicioSel(s);
setLoadingSlots(true); setLoadingSlots(true);
setStep("slot"); setSlots([]);
const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}`); setDiaSeleccionado(null);
setSlotSel(null);
setStep("calendario");
const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}&jefeMecanicoId=${jefeSel.id}`);
const data = await res.json(); const data = await res.json();
setSlots(data); 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">
{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">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>
<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,111 +244,236 @@ 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("servicio")}>←</Button> <Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
) : step === "servicio" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</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 === "jefe" && "Elegí el jefe mecánico"}
{step === "servicio" && `Trabajos de ${jefeSel?.nombre}`}
{step === "calendario" && `${jefeSel?.nombre}${servicioSel ? ` · ${servicioSel.nombre}` : ""}`}
</h1> </h1>
</div> </div>
{step === "servicio" && ( {/* STEP: jefe */}
{step === "jefe" && (
<> <>
<div className="grid gap-3"> <div className="flex flex-col gap-3">
{servicios.map((s) => ( {loadingJefes ? (
<button <p className="text-center text-sm text-gray-200">Cargando jefes...</p>
key={s.id} ) : jefes.length === 0 ? (
onClick={() => seleccionarServicio(s)} <Card>
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" <p className="text-sm text-gray-600 text-center">
> No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
<div> </p>
<p className="font-medium text-gray-800">{s.nombre}</p> </Card>
<p className="text-sm text-gray-500 mt-0.5"> ) : (
Duración: {s.duracionHoras === 1 ? "1 hora" : `${s.duracionHoras} horas`} jefes.map((j) => (
</p> <button
</div> key={j.id}
<span className="text-gray-400 text-lg">→</span> onClick={() => seleccionarJefe(j)}
</button> className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
))} >
<div>
<p className="font-medium text-gray-800">{j.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Ver sus trabajos</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
))
)}
</div> </div>
{!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: servicio */}
<div className="flex flex-col gap-4"> {step === "servicio" && (
{loadingSlots ? ( <div className="grid gap-3">
<div className="text-center py-10 text-gray-400">Buscando horarios disponibles...</div> {loadingServicios ? (
) : slots.length === 0 ? ( <p className="text-center text-sm text-gray-200">Cargando trabajos...</p>
) : servicios.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. {jefeSel?.nombre} todavía no cargó trabajos. Elegí otro mecánico.
</p> </p>
</Card> </Card>
) : ( ) : (
<> servicios.map(s => (
<p className="text-sm text-gray-500"> <button key={s.id} onClick={() => seleccionarServicio(s)}
Servicio: <strong>{servicioSel?.nombre}</strong> 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">
</p> <div>
<div className="grid gap-2"> <p className="font-medium text-gray-800">{s.nombre}</p>
{slots.map((slot, i) => { <p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
const { dia, horas } = formatSlot(slot); </div>
const sel = slotSel?.fechaInicio === slot.fechaInicio; <span className="text-gray-400 text-lg">→</span>
return ( </button>
<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
</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
View File
@@ -3,20 +3,19 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
--background: #ffffff;
--foreground: #171717; --foreground: #171717;
} }
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body { body {
color: var(--foreground); color: var(--foreground);
background: var(--background); background-image:
linear-gradient(rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.35)),
url('/tallermg.jpeg');
background-size: cover;
background-position: center;
background-attachment: fixed;
background-repeat: no-repeat;
min-height: 100vh;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import Card from "@/components/ui/Card";
import Badge from "@/components/ui/Badge";
interface Turno {
id: string;
fechaInicio: string;
fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
comentario: string | null;
kilometros: number | null;
tipoServicio: { nombre: string };
auto: { marca: string; modelo: string; patente: string } | null;
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 ? `${t.auto.marca} ${t.auto.modelo}` : <em className="text-amber-600">a completar</em>}</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 != null ? `${t.kilometros.toLocaleString("es-AR")} km` : <em className="text-amber-600">a completar</em>}</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>
);
}
+54
View File
@@ -0,0 +1,54 @@
"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}</>;
const tabs = [
{ href: "/jefe/dashboard", label: "Turnos" },
{ href: "/jefe/servicios", label: "Trabajos" },
];
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>
<nav className="flex gap-1">
{tabs.map((t) => {
const activo = pathname === t.href;
return (
<button
key={t.href}
onClick={() => router.push(t.href)}
className={`text-sm px-4 py-2 rounded-lg transition ${
activo
? "bg-white/15 text-white font-medium"
: "text-gray-300 hover:text-white hover:bg-white/10"
}`}
>
{t.label}
</button>
);
})}
</nav>
<main className="flex-1">{children}</main>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card";
export default function JefeLoginPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
function set(f: string) {
return (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((v) => ({ ...v, [f]: e.target.value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/auth/jefe-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
setLoading(false);
if (!res.ok) { setError(data.error || "Credenciales incorrectas"); return; }
router.push("/jefe/dashboard");
}
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<Card className="w-full max-w-sm mx-4">
<div className="text-center mb-6">
<div className="text-4xl mb-2">🔧</div>
<h1 className="text-xl font-bold text-gray-800">Agenda del jefe mecánico</h1>
<p className="text-sm text-gray-500 mt-1">Ingresá con tus credenciales</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input id="email" type="email" label="Email" placeholder="jefe@taller.com" value={form.email} onChange={set("email")} required />
<Input id="password" type="password" label="Contraseña" placeholder="••••••••" value={form.password} onChange={set("password")} required />
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
<Button type="submit" loading={loading} className="w-full">Ingresar</Button>
</form>
</Card>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
"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 Servicio {
id: string;
nombre: string;
duracionHoras: number;
activo: boolean;
}
export default function JefeServicios() {
const [servicios, setServicios] = useState<Servicio[]>([]);
const [loading, setLoading] = useState(true);
const [nombre, setNombre] = useState("");
const [duracion, setDuracion] = useState("");
const [error, setError] = useState<string | null>(null);
const [guardando, setGuardando] = useState(false);
useEffect(() => { cargar(); }, []);
async function cargar() {
setLoading(true);
const res = await fetch("/api/jefe/servicios");
const data = await res.json();
setServicios(Array.isArray(data) ? data : []);
setLoading(false);
}
async function agregar(e: React.FormEvent) {
e.preventDefault();
setError(null);
setGuardando(true);
const res = await fetch("/api/jefe/servicios", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nombre, duracionHoras: duracion }),
});
setGuardando(false);
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d.error ?? "No se pudo guardar el trabajo");
return;
}
setNombre("");
setDuracion("");
cargar();
}
async function toggleActivo(s: Servicio) {
await fetch(`/api/jefe/servicios/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activo: !s.activo }),
});
cargar();
}
async function editarDuracion(s: Servicio) {
const valor = prompt(`Duración de "${s.nombre}" en horas:`, String(s.duracionHoras));
if (valor == null) return;
const res = await fetch(`/api/jefe/servicios/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ duracionHoras: valor }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(d.error ?? "No se pudo actualizar");
return;
}
cargar();
}
async function eliminar(s: Servicio) {
if (!confirm(`¿Eliminar el trabajo "${s.nombre}"?`)) return;
const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "DELETE" });
const d = await res.json().catch(() => ({}));
if (d.desactivado)
alert("El trabajo ya tenía turnos asociados, así que se desactivó en vez de borrarse.");
cargar();
}
return (
<div className="flex flex-col gap-6">
<h2 className="text-xl sm:text-2xl font-bold text-white">Mis trabajos y tiempos</h2>
<Card>
<form onSubmit={agregar} className="flex flex-col gap-3">
<p className="text-sm text-gray-600">
Cargá los trabajos que ofrecés y cuánto tardan. Son los que verá el cliente al reservar por WhatsApp.
</p>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-3 items-end">
<Input
label="Trabajo"
placeholder="Ej: Cambio de aceite"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
required
/>
<Input
label="Duración (horas)"
type="text"
inputMode="decimal"
placeholder="Ej: 1.5"
value={duracion}
onChange={(e) => setDuracion(e.target.value)}
className="sm:w-40"
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div>
<Button type="submit" loading={guardando}>Agregar trabajo</Button>
</div>
</form>
</Card>
{loading ? (
<div className="text-center py-10 text-gray-200">Cargando...</div>
) : servicios.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">Todavía no cargaste ningún trabajo.</p>
</Card>
) : (
<div className="flex flex-col gap-3">
{servicios.map((s) => (
<Card key={s.id}>
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<p className={`font-semibold ${s.activo ? "text-gray-800" : "text-gray-400 line-through"}`}>
{s.nombre}
</p>
<p className="text-sm text-gray-500 mt-0.5">
{s.duracionHoras} {s.duracionHoras === 1 ? "hora" : "horas"}
{!s.activo && " · inactivo"}
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="text-sm" onClick={() => editarDuracion(s)}>
Duración
</Button>
<Button variant="secondary" className="text-sm" onClick={() => toggleActivo(s)}>
{s.activo ? "Desactivar" : "Activar"}
</Button>
<Button variant="danger" className="text-sm" onClick={() => eliminar(s)}>
Eliminar
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}
+2 -2
View File
@@ -14,8 +14,8 @@ const geistMono = localFont({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Taller Beldor",
description: "Generated by create next app", description: "Sistema de turnos - Taller Beldor",
}; };
export default function RootLayout({ export default function RootLayout({
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface ComboboxProps {
id?: string;
label?: string;
options: string[];
value: string;
onChange: (value: string) => void;
onSelect?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
}
export default function Combobox({
id, label, options, value, onChange, onSelect,
placeholder, disabled, required, className,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const filtered = value
? options.filter((o) => o.toLowerCase().includes(value.toLowerCase())).slice(0, 60)
: options.slice(0, 60);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
function handleSelect(option: string) {
onChange(option);
onSelect?.(option);
setOpen(false);
}
return (
<div className={`relative ${className ?? ""}`} ref={ref}>
{label && (
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
<input
id={id}
type="text"
value={value}
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
required={required}
autoComplete="off"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
/>
{open && !disabled && filtered.length > 0 && (
<ul className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-52 overflow-y-auto">
{filtered.map((option) => (
<li
key={option}
onMouseDown={() => handleSelect(option)}
className="px-3 py-2 text-sm cursor-pointer hover:bg-blue-50 hover:text-blue-700"
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
+25 -8
View File
@@ -3,9 +3,16 @@ import { InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string; label?: string;
error?: string; error?: string;
/** Texto fijo grisado antes del campo (ej: el prefijo de país del teléfono). */
prefijo?: string;
ayuda?: string;
} }
export default function Input({ label, error, className = "", id, ...props }: InputProps) { export default function Input({ label, error, prefijo, ayuda, className = "", id, ...props }: InputProps) {
const estiloInput = `border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition bg-gray-100 text-gray-900 placeholder-gray-500 ${
error ? "border-red-400" : "border-gray-400"
} ${className}`;
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{label && ( {label && (
@@ -13,13 +20,23 @@ export default function Input({ label, error, className = "", id, ...props }: In
{label} {label}
</label> </label>
)} )}
<input {prefijo ? (
id={id} <div
className={`border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition bg-gray-100 text-gray-900 placeholder-gray-500 ${ className={`flex items-center rounded-lg border bg-gray-100 focus-within:ring-2 focus-within:ring-blue-500 transition ${
error ? "border-red-400" : "border-gray-400" error ? "border-red-400" : "border-gray-400"
} ${className}`} }`}
{...props} >
/> <span className="pl-3 pr-1 text-sm text-gray-500 select-none">{prefijo}</span>
<input
id={id}
className={`flex-1 bg-transparent px-1 py-2 text-sm outline-none text-gray-900 placeholder-gray-500 ${className}`}
{...props}
/>
</div>
) : (
<input id={id} className={estiloInput} {...props} />
)}
{ayuda && !error && <p className="text-xs text-gray-500">{ayuda}</p>}
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-red-500">{error}</p>}
</div> </div>
); );
+16 -2
View File
@@ -19,15 +19,29 @@ 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,
rolEsperado?: "admin" | "cliente" | "jefe"
): Promise<{ sub: string; role: string } | null> { ): Promise<{ sub: string; role: string } | null> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get(cookieName)?.value; const token = cookieStore.get(cookieName)?.value;
if (!token) return null; if (!token) return null;
try { try {
const { payload } = await jwtVerify(token, SECRET); const { payload } = await jwtVerify(token, SECRET);
return payload as { sub: string; role: string }; const sesion = payload as { sub: string; role: string };
// Los tres tipos de sesión se firman con el mismo secreto, así que un token de
// admin o de jefe puesto en la cookie del cliente pasa la verificación de firma.
// Cuando el rol importa, se comprueba acá.
if (rolEsperado && sesion.role !== rolEsperado) return null;
return sesion;
} catch { } catch {
return null; return null;
} }
+102
View File
@@ -0,0 +1,102 @@
import crypto from "crypto";
const SHEET_ID = process.env.GOOGLE_SHEET_ID!;
const SHEET_TAB = "Hoja 1";
async function getAccessToken(): Promise<string> {
const clientEmail = process.env.GOOGLE_CLIENT_EMAIL!;
const privateKey = process.env.GOOGLE_PRIVATE_KEY!.replace(/\\n/g, "\n");
const now = Math.floor(Date.now() / 1000);
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
const payload = Buffer.from(
JSON.stringify({
iss: clientEmail,
scope: "https://www.googleapis.com/auth/spreadsheets",
aud: "https://oauth2.googleapis.com/token",
exp: now + 3600,
iat: now,
})
).toString("base64url");
const sign = crypto.createSign("RSA-SHA256");
sign.update(`${header}.${payload}`);
const signature = sign.sign(privateKey, "base64url");
const jwt = `${header}.${payload}.${signature}`;
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
}),
});
const data = await res.json() as { access_token: string };
return data.access_token;
}
export interface FilaTaller {
patente: string;
kilometros: number;
marca: string;
modelo: string;
anio: number;
nombre: string;
apellido: string;
email: string;
telefono: string;
fecha: Date;
trabajo: string;
comentario?: string | null;
}
export async function agregarFilaSheet(fila: FilaTaller) {
const token = await getAccessToken();
const fecha = fila.fecha.toLocaleDateString("es-AR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
// K=GASTOS, L=VARIOS, M=M DE OBRA son manuales — se dejan vacíos
const valores = [
fila.patente,
fila.kilometros,
fila.marca,
fila.modelo,
fila.anio,
fila.nombre,
fila.apellido,
fecha,
fila.email,
fila.telefono,
"", // K: GASTOS
"", // L: VARIOS
"", // M: M DE OBRA
fila.comentario ?? "", // N: Comentarios
];
// Anclamos en A1 para que la API siempre busque desde el inicio de la tabla.
// INSERT_ROWS fuerza una fila nueva, evita que desplace los datos cuando hay
// celdas ocupadas en columnas manuales (GASTOS, VARIOS, etc.).
const range = encodeURIComponent(`${SHEET_TAB}!A1`);
const url = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/values/${range}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ values: [valores] }),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Sheets API error: ${err}`);
}
}
+14 -7
View File
@@ -1,21 +1,28 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { Resend } from "resend"; import nodemailer from "nodemailer";
import crypto from "crypto"; import crypto from "crypto";
const resend = new Resend(process.env.RESEND_API_KEY); const transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: {
user: process.env.GMAIL_USER,
pass: process.env.GMAIL_APP_PASSWORD,
},
});
export async function enviarMagicLink(email: string): Promise<{ ok: boolean; error?: string }> { export async function enviarMagicLink(email: string): Promise<{ ok: boolean; error?: string }> {
const cliente = await prisma.cliente.findUnique({ where: { email } }); const cliente = await prisma.cliente.findUnique({ where: { email } });
if (!cliente) return { ok: false, error: "Email no registrado" }; if (!cliente) return { ok: false, error: "Email no registrado" };
// Invalidar tokens anteriores no usados
await prisma.magicToken.updateMany({ await prisma.magicToken.updateMany({
where: { clienteId: cliente.id, usado: false }, where: { clienteId: cliente.id, usado: false },
data: { usado: true }, data: { usado: true },
}); });
const token = crypto.randomBytes(32).toString("hex"); const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutos const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await prisma.magicToken.create({ await prisma.magicToken.create({
data: { clienteId: cliente.id, token, expiresAt }, data: { clienteId: cliente.id, token, expiresAt },
@@ -23,9 +30,9 @@ export async function enviarMagicLink(email: string): Promise<{ ok: boolean; err
const link = `${process.env.MAGIC_LINK_BASE_URL}/cliente/auth?token=${token}`; const link = `${process.env.MAGIC_LINK_BASE_URL}/cliente/auth?token=${token}`;
await resend.emails.send({ await transporter.sendMail({
from: "Taller <onboarding@resend.dev>", from: `"Taller" <${process.env.GMAIL_USER}>`,
to: "jpbeldomenico@gmail.com", to: email,
subject: "Tu link de acceso al taller", subject: "Tu link de acceso al taller",
html: ` html: `
<h2>Hola ${cliente.nombre}!</h2> <h2>Hola ${cliente.nombre}!</h2>
+70
View File
@@ -0,0 +1,70 @@
export const MARCAS_MODELOS: Record<string, string[]> = {
"Acura": ["ILX", "Integra", "MDX", "RDX", "TLX"],
"Alfa Romeo": ["147", "156", "159", "Brera", "Giulia", "Giulietta", "GT", "MiTo", "Spider", "Stelvio", "Tonale"],
"Aston Martin": ["DB11", "DB9", "DBX", "DBS", "Rapide", "Vantage", "Virage"],
"Audi": ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "e-tron", "e-tron GT", "Q2", "Q3", "Q4 e-tron", "Q5", "Q7", "Q8", "R8", "RS3", "RS4", "RS5", "RS6", "RS7", "S3", "S4", "S5", "S6", "S7", "S8", "SQ5", "SQ7", "SQ8", "TT", "TT RS", "TTS"],
"Bentley": ["Bentayga", "Continental GT", "Continental GTC", "Flying Spur", "Mulsanne"],
"BMW": ["116i", "118i", "120i", "218i", "220i", "316i", "318i", "320i", "325i", "330i", "335i", "418i", "420i", "430i", "440i", "520i", "525i", "530i", "540i", "545i", "550i", "630i", "740i", "750i", "M2", "M3", "M4", "M5", "M8", "X1", "X2", "X3", "X4", "X5", "X6", "X7", "Z4"],
"BYD": ["Atto 3", "Dolphin", "Han", "Seal", "Song", "Song Plus", "Tang", "Yuan Plus"],
"Buick": ["Enclave", "Encore", "Encore GX", "Envision", "LaCrosse", "Regal"],
"Cadillac": ["ATS", "CT4", "CT5", "CTS", "Escalade", "SRX", "XT4", "XT5", "XT6"],
"Chery": ["Arrizo 5", "Arrizo 5 Pro", "Arrizo 6", "Arrizo 6 Pro", "Tiggo 2", "Tiggo 2 Pro", "Tiggo 3", "Tiggo 3X", "Tiggo 4", "Tiggo 4 Pro", "Tiggo 5X", "Tiggo 7", "Tiggo 7 Pro", "Tiggo 8", "Tiggo 8 Pro"],
"Chevrolet": ["Agile", "Blazer", "Bolt", "Camaro", "Captiva", "Colorado", "Corvette", "Cruze", "Equinox", "Montana", "Onix", "Onix Plus", "S10", "Silverado", "Spin", "Tahoe", "Tracker", "Trailblazer", "Traverse", "Trax"],
"Chrysler": ["300", "300C", "Pacifica", "Sebring", "Voyager"],
"Citroën": ["Berlingo", "C-Elysée", "C1", "C2", "C3", "C3 Aircross", "C4", "C4 Cactus", "C5", "C5 Aircross", "C5 X", "C6", "C8", "DS3", "DS4", "DS5", "Jumper", "Jumpy", "Nemo", "SpaceTourer", "Xantia", "Xsara"],
"Dacia": ["Duster", "Jogger", "Logan", "Sandero", "Spring"],
"Dodge": ["Challenger", "Charger", "Dart", "Durango", "Journey", "RAM 1000", "RAM 1500", "RAM 2500", "Viper"],
"DS": ["DS 3", "DS 3 Crossback", "DS 4", "DS 5", "DS 7", "DS 7 Crossback", "DS 9"],
"Ferrari": ["296 GTB", "296 GTS", "812 Competizione", "812 Superfast", "F8 Spider", "F8 Tributo", "GTC4Lusso", "Portofino", "Portofino M", "Purosangue", "Roma", "SF90 Spider", "SF90 Stradale"],
"Fiat": ["124 Spider", "500", "500L", "500X", "Argo", "Bravo", "Cronos", "Doblo", "Ducato", "Fiorino", "Linea", "Mobi", "Palio", "Punto", "Qubo", "Siena", "Strada", "Toro", "Tipo", "Uno"],
"Ford": ["Bronco", "Bronco Sport", "C-Max", "EcoSport", "Edge", "Escape", "Expedition", "Explorer", "F-150", "F-150 Lightning", "Fiesta", "Focus", "Fusion", "Galaxy", "Ka", "Ka+", "Kuga", "Maverick", "Mondeo", "Mustang", "Mustang Mach-E", "Puma", "Ranger", "S-Max", "Territory", "Transit", "Transit Connect", "Transit Custom"],
"Geely": ["Atlas", "Coolray", "Emgrand", "Monjaro", "Okavango", "Preface", "Tugella"],
"Genesis": ["G70", "G80", "G90", "GV70", "GV80"],
"GMC": ["Acadia", "Canyon", "Envoy", "Sierra", "Terrain", "Yukon"],
"Great Wall": ["Cannon", "Haval H1", "Haval H2", "Haval H4", "Haval H6", "Haval H9", "Haval Jolion", "Ora Funky Cat", "Poer", "Wingle"],
"Haval": ["F7", "H1", "H2", "H4", "H6", "H9", "Jolion"],
"Honda": ["Accord", "City", "Civic", "CR-V", "CR-Z", "Element", "Fit", "HR-V", "Insight", "Jazz", "Legend", "Odyssey", "Passport", "Pilot", "Ridgeline", "WR-V"],
"IKA-Renault": ["Carabela", "Jeep", "Rambler", "Torino"],
"Hummer": ["EV", "H1", "H2", "H3"],
"Hyundai": ["Accent", "Azera", "Creta", "Elantra", "Getz", "HB20", "i10", "i20", "i30", "i40", "Ioniq", "Ioniq 5", "Ioniq 6", "ix35", "Kona", "Santa Cruz", "Santa Fe", "Sonata", "Terracan", "Tucson", "Veloster", "Venue"],
"Infiniti": ["Q30", "Q40", "Q50", "Q60", "Q70", "QX30", "QX50", "QX55", "QX60", "QX70", "QX80"],
"JAC": ["E10X", "J7", "JS4", "S2", "S3", "S4", "S7", "T6", "T8"],
"Jaguar": ["E-Pace", "F-Pace", "F-Type", "I-Pace", "XE", "XF", "XJ"],
"Jeep": ["Avenger", "Cherokee", "Commander", "Compass", "Gladiator", "Grand Cherokee", "Grand Cherokee 4xe", "Renegade", "Wagoneer", "Wrangler"],
"Kia": ["Carnival", "Cerato", "Ceed", "EV6", "Picanto", "Rio", "Seltos", "Sonet", "Sorento", "Soul", "Sportage", "Stinger", "Stonic", "Telluride"],
"Lamborghini": ["Aventador", "Huracán", "Revuelto", "Urus"],
"Land Rover": ["Defender", "Discovery", "Discovery Sport", "Freelander", "Range Rover", "Range Rover Evoque", "Range Rover Sport", "Range Rover Velar"],
"Lexus": ["CT", "ES", "GS", "GX", "IS", "LC", "LS", "LX", "NX", "RC", "RX", "UX"],
"Lifan": ["530", "620", "720", "820", "X50", "X60", "X70", "X80"],
"Lincoln": ["Aviator", "Corsair", "MKC", "MKT", "MKX", "MKZ", "Nautilus", "Navigator"],
"Lotus": ["Eletre", "Emira", "Evija"],
"Maserati": ["Ghibli", "GranCabrio", "GranTurismo", "Grecale", "Levante", "MC20", "Quattroporte"],
"Mazda": ["2", "3", "6", "BT-50", "CX-3", "CX-30", "CX-5", "CX-50", "CX-60", "CX-9", "MX-30", "MX-5", "RX-8"],
"McLaren": ["570S", "600LT", "650S", "720S", "750S", "Artura", "GT"],
"Mercedes-Benz": ["A 180", "A 200", "A 250", "AMG GT", "B 200", "C 180", "C 200", "C 300", "CLA 200", "CLA 250", "CLC 200", "CLE 200", "CLK 200", "CLS 350", "E 200", "E 250", "E 300", "E 350", "G 500", "G 63 AMG", "GLA 200", "GLA 250", "GLB 200", "GLC 200", "GLC 300", "GLE 350", "GLS 400", "ML 350", "S 400", "S 500", "Sprinter", "Vito"],
"MG": ["EZS", "GS", "HS", "MG3", "MG5", "RX5", "ZS", "ZS EV"],
"Mini": ["Clubman", "Convertible", "Cooper", "Cooper S", "Countryman", "JCW", "Paceman"],
"Mitsubishi": ["ASX", "Eclipse Cross", "Galant", "L200", "Lancer", "Montero", "Outlander", "Pajero", "Pajero Sport"],
"Nissan": ["370Z", "Frontier", "GT-R", "Juke", "Kicks", "Leaf", "March", "Micra", "Murano", "Note", "Pathfinder", "Qashqai", "Sentra", "Tiida", "Versa", "X-Trail"],
"Opel": ["Astra", "Corsa", "Crossland", "Grandland", "Insignia", "Meriva", "Mokka", "Vectra", "Vivaro", "Zafira"],
"Peugeot": ["1007", "107", "108", "2008", "206", "207", "208", "3008", "301", "307", "308", "408", "5008", "508", "Boxer", "Expert", "Partner", "Rifter"],
"Porsche": ["718 Boxster", "718 Cayman", "911", "Cayenne", "Macan", "Panamera", "Taycan"],
"RAM": ["700", "1000", "1500", "2500", "3500", "Promaster"],
"Renault": ["Alaskan", "Arkana", "Austral", "Captur", "Clio", "Duster", "Fluence", "Kadjar", "Kangoo", "Koleos", "Laguna", "Logan", "Master", "Megane", "Rafale", "Sandero", "Scenic", "Symbol", "Trafic", "Zoe"],
"Rolls-Royce": ["Cullinan", "Dawn", "Ghost", "Phantom", "Silver Shadow", "Spectre", "Wraith"],
"SEAT": ["Arona", "Ateca", "Ibiza", "León", "Tarraco"],
"Škoda": ["Fabia", "Kamiq", "Karoq", "Kodiaq", "Octavia", "Scala", "Superb"],
"Smart": ["EQ fortwo", "EQ forfour", "forfour", "fortwo"],
"Subaru": ["Ascent", "BRZ", "Crosstrek", "Forester", "Impreza", "Legacy", "Outback", "Solterra", "WRX", "XV"],
"Suzuki": ["Baleno", "Celerio", "Dzire", "Ertiga", "Grand Vitara", "Ignis", "Jimny", "S-Cross", "Splash", "SX4", "Swift", "Vitara"],
"Tesla": ["Cybertruck", "Model 3", "Model S", "Model X", "Model Y", "Roadster"],
"Toyota": ["4Runner", "86", "Aqua", "Avalon", "C-HR", "Camry", "Corolla", "Corolla Cross", "Crown", "Etios", "Fortuner", "GR86", "Hilux", "Land Cruiser", "Land Cruiser Prado", "Prius", "RAV4", "Rush", "Sequoia", "SW4", "Tacoma", "Tundra", "Vios", "Yaris"],
"Volkswagen": ["Amarok", "Bora", "Caddy", "Crafter", "Fox", "Gol", "Golf", "Jetta", "Nivus", "Passat", "Polo", "Saveiro", "Suran", "T-Cross", "T-Roc", "Taigo", "Taos", "Tiguan", "Touareg", "Touran", "Transporter", "Up!", "Vento", "Virtus"],
"Volvo": ["C30", "C40", "S40", "S60", "S80", "S90", "V40", "V60", "V90", "XC40", "XC60", "XC70", "XC90"],
};
export const MARCAS = Object.keys(MARCAS_MODELOS).sort((a, b) => a.localeCompare(b));
export function getModelos(marca: string): string[] {
return MARCAS_MODELOS[marca] ?? [];
}
+98 -37
View File
@@ -1,92 +1,153 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
const SLOTS_PARALELOS = 4; // 2 jefes × 2 autos c/u /**
const HORA_INICIO = 8; * Cliente de Prisma o el cliente de una transacción en curso. Permite que la
const HORA_FIN = 18; * verificación de disponibilidad corra dentro de la misma transacción que crea
* el turno (ver `crearTurno`), que es lo que evita reservas superpuestas.
*/
type ClienteDb = Prisma.TransactionClient;
const INICIO_H = 8;
const INICIO_M = 30;
const FIN_H = 17;
const FIN_M = 0;
const INICIO_MINUTOS = INICIO_H * 60 + INICIO_M; // 510
const FIN_MINUTOS = FIN_H * 60 + FIN_M; // 1020
// Argentina es siempre UTC-3 (sin horario de verano desde 2008)
const ARG_OFFSET_MIN = -180;
// Minutos del día en hora Argentina (0-1439)
function minutosArgentina(fecha: Date): number {
const utcMin = fecha.getUTCHours() * 60 + fecha.getUTCMinutes();
return ((utcMin + ARG_OFFSET_MIN) + 1440) % 1440;
}
// Día de la semana en Argentina (0=Dom, 6=Sab)
function diaArgentina(fecha: Date): number {
const arg = new Date(fecha.getTime() + ARG_OFFSET_MIN * 60 * 1000);
return arg.getUTCDay();
}
// Componentes de fecha Argentina (para construir fechas UTC)
function fechaArgentina(fecha: Date): { year: number; month: number; date: number } {
const arg = new Date(fecha.getTime() + ARG_OFFSET_MIN * 60 * 1000);
return { year: arg.getUTCFullYear(), month: arg.getUTCMonth(), date: arg.getUTCDate() };
}
// Devuelve la fecha UTC correspondiente a las 8:30 Argentina del día que contiene `fecha`
function inicioJornada(fecha: Date): Date {
const { year, month, date } = fechaArgentina(fecha);
// 8:30 Argentina + 3h = 11:30 UTC
return new Date(Date.UTC(year, month, date, INICIO_H - ARG_OFFSET_MIN / 60, INICIO_M, 0, 0));
}
function esDiaHabil(fecha: Date): boolean { 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,
const count = await prisma.turno.count({ fin: Date,
jefeMecanicoId: string,
db: ClienteDb = prisma
): Promise<number> {
const count = await db.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, db: ClienteDb = prisma) {
return db.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,
db: ClienteDb = prisma
): 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, db);
return ocupados < SLOTS_PARALELOS; if (!jefe || !jefe.activo) return false;
const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId, db);
return ocupados < jefe.slotsParalelos;
} }
+24
View File
@@ -0,0 +1,24 @@
import type { Prisma, TipoServicio } from "@prisma/client";
/**
* Regla única de qué trabajos ofrece un jefe mecánico.
*
* Los jefes cargan sus propios trabajos desde `/jefe/servicios` (`jefeMecanicoId` = su id),
* y el admin puede cargar trabajos globales del taller (`jefeMecanicoId` = null) que ofrece
* cualquiera. El cliente elige primero el mecánico y después el trabajo, así que la lista
* siempre es: los del jefe + los globales.
*/
export function whereTrabajosDeJefe(jefeMecanicoId: string): Prisma.TipoServicioWhereInput {
return {
activo: true,
OR: [{ jefeMecanicoId }, { jefeMecanicoId: null }],
};
}
/** Un trabajo propio de otro jefe no se puede reservar con este. Los globales sí. */
export function trabajoEsDeJefe(
servicio: Pick<TipoServicio, "jefeMecanicoId">,
jefeMecanicoId: string
): boolean {
return servicio.jefeMecanicoId === null || servicio.jefeMecanicoId === jefeMecanicoId;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Normaliza un identificador de teléfono a solo dígitos (estilo E.164 sin el '+').
* WhatsApp Cloud API entrega el `wa_id` ya así (ej: "5493511234567"), pero lo pasamos
* por acá para que el matcheo sea consistente venga de donde venga.
*/
export function normalizarTelefono(valor: string): string {
return (valor ?? "").replace(/\D/g, "");
}
/**
* Prefijo con el que WhatsApp identifica a los celulares argentinos: 54 (país) + 9 (móvil).
* En el registro web se muestra fijo y el cliente completa el resto, así el número queda
* guardado igual que el `wa_id` que después manda Meta y el bot lo reconoce.
*/
export const PREFIJO_WHATSAPP_AR = "549";
/**
* Parte local del número: característica sin el 0 y abonado sin el 15.
* En Argentina son siempre 10 dígitos (ej: 11 4198-4844, 351 555-4444).
*/
export function esLocalArgentinoValido(local: string): boolean {
const d = normalizarTelefono(local);
// Ninguna característica argentina empieza con 0 (es el prefijo de larga distancia)
// ni con 15 (el viejo prefijo de celular), así que esos casos son error de tipeo.
return d.length === 10 && !d.startsWith("0") && !d.startsWith("15");
}
/** Número local a formato WhatsApp: 1141984844 → 5491141984844. */
export function aFormatoWhatsapp(local: string): string {
return PREFIJO_WHATSAPP_AR + normalizarTelefono(local);
}
/**
* Inverso del anterior: 5491141984844 → 1141984844. Sirve para mostrar el número
* en el formulario (donde el +54 9 es un prefijo fijo), venga de un alta web o de
* una por WhatsApp, que lo guarda con el prefijo puesto.
*/
export function aLocalArgentino(valor: string): string {
const d = normalizarTelefono(valor);
return d.startsWith(PREFIJO_WHATSAPP_AR) ? d.slice(PREFIJO_WHATSAPP_AR.length) : d;
}
+70
View File
@@ -0,0 +1,70 @@
import { prisma } from "./prisma";
import { slotEstaDisponible } from "./scheduling";
import { trabajoEsDeJefe } from "./servicios";
export interface CrearTurnoInput {
clienteId: string;
jefeMecanicoId: string;
tipoServicioId: string;
fechaInicio: Date;
autoId?: string | null;
kilometros?: number | null;
}
export type CrearTurnoResultado =
| { ok: true; turno: Awaited<ReturnType<typeof prisma.turno.create>> }
| { ok: false; error: string; status: number };
/**
* Crea un turno validando servicio/jefe activos y disponibilidad del slot.
* Lo usan tanto el flujo web (con auto+km) como el bot de WhatsApp (sin auto+km).
* La duración del turno la define el TipoServicio.
*/
export async function crearTurno(input: CrearTurnoInput): Promise<CrearTurnoResultado> {
const { clienteId, jefeMecanicoId, tipoServicioId, fechaInicio } = input;
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo)
return { ok: false, error: "Servicio no disponible", status: 404 };
const jefe = await prisma.jefeMecanico.findUnique({ where: { id: jefeMecanicoId } });
if (!jefe || !jefe.activo)
return { ok: false, error: "Jefe mecánico no disponible", status: 404 };
// Un trabajo propio de otro jefe no se reserva con éste (los globales sí).
if (!trabajoEsDeJefe(servicio, jefeMecanicoId))
return { ok: false, error: "Ese trabajo no lo hace este mecánico", status: 400 };
const fin = new Date(fechaInicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
// Verificar y crear en una sola transacción, serializando por jefe con un lock
// de Postgres: si no, dos clientes pueden pasar la verificación al mismo tiempo
// y quedar los dos reservados sobre la misma capacidad.
const turno = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${jefeMecanicoId}))`;
const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId, tx);
if (!disponible) return null;
return tx.turno.create({
data: {
clienteId,
jefeMecanicoId,
tipoServicioId,
fechaInicio,
fechaFin: fin,
autoId: input.autoId ?? null,
kilometros: input.kilometros ?? null,
},
include: {
auto: true,
tipoServicio: true,
jefeMecanico: { select: { id: true, nombre: true } },
},
});
});
if (!turno) return { ok: false, error: "El horario ya no está disponible", status: 409 };
return { ok: true, turno };
}
+373
View File
@@ -0,0 +1,373 @@
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
import { obtenerSlotsDisponibles } from "./scheduling";
import { crearTurno } from "./turnos";
import { normalizarTelefono } from "./telefono";
import { trabajoEsDeJefe, whereTrabajosDeJefe } from "./servicios";
// ── Tipos de respuesta que el bot le pide al canal (F0 los traduce a la API de Meta) ──
export type BotonBot = { id: string; titulo: string };
export type RespuestaBot =
| { tipo: "texto"; texto: string }
| { tipo: "botones"; texto: string; botones: BotonBot[] };
// ── Estado que se persiste en ConversacionWhatsapp.datos ──
interface OpcionMenu {
letra: string;
id: string;
label: string;
}
interface DatosConv {
nombre?: string;
apellido?: string;
jefeMecanicoId?: string;
jefeNombre?: string;
tipoServicioId?: string;
tipoNombre?: string;
duracionHoras?: number;
slotsOffset?: number;
slotInicio?: string; // ISO del slot elegido
slotLabel?: string;
opciones?: OpcionMenu[]; // opciones mostradas en el paso actual (letra → id)
intentosEmail?: number;
}
const EXPIRA_MIN = 30;
const SLOTS_POR_PAGINA = 8;
const LETRAS = "abcdefghijklmnopqrstuvwxyz";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SI = ["si", "sí", "s", "dale", "ok", "confirmo", "confirmar"];
const NO = ["no", "n", "cancelar", "cancela"];
const MAS = ["mas", "más", "+", "ver mas", "ver más"];
// ── Helpers de presentación ──
function formatSlot(fecha: Date): string {
return fecha.toLocaleString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires",
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function listaConLetras(items: { label: string }[]): { texto: string; opciones: OpcionMenu[] } {
const opciones: OpcionMenu[] = [];
const lineas = items.map((it, i) => {
const letra = LETRAS[i] ?? String(i + 1);
opciones.push({ letra, id: "", label: it.label });
return `${letra}) ${it.label}`;
});
return { texto: lineas.join("\n"), opciones };
}
function elegirOpcion(datos: DatosConv, input: string): OpcionMenu | undefined {
const t = input.trim().toLowerCase();
return (datos.opciones ?? []).find(
(o) => o.id.toLowerCase() === t || o.letra.toLowerCase() === t || o.label.toLowerCase() === t
);
}
// ── Construcción de pasos reutilizables ──
async function pasoElegirMecanico(): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
const jefes = await prisma.jefeMecanico.findMany({
where: { activo: true },
orderBy: { nombre: "asc" },
select: { id: true, nombre: true },
});
if (jefes.length === 0)
return {
resp: [{ tipo: "texto", texto: "No hay mecánicos disponibles en este momento. Probá más tarde." }],
datos: {},
paso: "INICIO",
};
const opciones: OpcionMenu[] = jefes.map((j, i) => ({ letra: String(i + 1), id: j.id, label: j.nombre }));
const botones: BotonBot[] = jefes.slice(0, 3).map((j) => ({ id: j.id, titulo: j.nombre.slice(0, 20) }));
return {
resp: [{ tipo: "botones", texto: "¿Con qué mecánico querés atenderte?", botones }],
datos: { opciones },
paso: "ELEGIR_MECANICO",
};
}
async function pasoElegirTrabajo(datos: DatosConv): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
const servicios = await prisma.tipoServicio.findMany({
where: whereTrabajosDeJefe(datos.jefeMecanicoId!),
orderBy: { nombre: "asc" },
});
if (servicios.length === 0) {
const m = await pasoElegirMecanico();
return {
resp: [{ tipo: "texto", texto: `${datos.jefeNombre} todavía no cargó trabajos. Elegí otro mecánico.` }, ...m.resp],
datos: m.datos,
paso: m.paso,
};
}
const { texto, opciones } = listaConLetras(
servicios.map((s) => ({ label: `${s.nombre} (${s.duracionHoras}h)` }))
);
// Guardar el id real de cada trabajo en las opciones
servicios.forEach((s, i) => (opciones[i].id = s.id));
return {
resp: [
{
tipo: "texto",
texto: `Trabajos de ${datos.jefeNombre}:\n\n${texto}\n\nRespondé con la letra de la opción.`,
},
],
datos: { ...datos, opciones },
paso: "ELEGIR_TRABAJO",
};
}
async function pasoMostrarHorarios(
datos: DatosConv,
offset: number
): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
const slots = await obtenerSlotsDisponibles(datos.jefeMecanicoId!, datos.duracionHoras ?? 1, 30, 200);
if (slots.length === 0)
return {
resp: [
{ tipo: "texto", texto: "No hay horarios disponibles en los próximos días. Probá más tarde. 🙏" },
],
datos: {},
paso: "INICIO",
};
const off = offset >= slots.length ? 0 : offset;
const pagina = slots.slice(off, off + SLOTS_POR_PAGINA);
const opciones: OpcionMenu[] = pagina.map((s, i) => ({
letra: LETRAS[i] ?? String(i + 1),
id: s.fechaInicio.toISOString(),
label: formatSlot(s.fechaInicio),
}));
const lista = opciones.map((o) => `${o.letra}) ${o.label}`).join("\n");
const hayMas = off + SLOTS_POR_PAGINA < slots.length;
let texto = `Horarios disponibles con ${datos.jefeNombre} para ${datos.tipoNombre}:\n\n${lista}\n\nRespondé con la letra.`;
if (hayMas) texto += `\nEscribí *más* para ver otros horarios.`;
return {
resp: [{ tipo: "texto", texto }],
datos: { ...datos, opciones, slotsOffset: off },
paso: "ELEGIR_HORARIO",
};
}
// ── Persistencia ──
async function guardar(
convId: string,
paso: string,
datos: DatosConv,
resp: RespuestaBot[],
clienteId?: string | null
): Promise<RespuestaBot[]> {
await prisma.conversacionWhatsapp.update({
where: { id: convId },
data: {
paso,
datos: datos as unknown as Prisma.InputJsonValue,
...(clienteId !== undefined && { clienteId }),
},
});
return resp;
}
const BIENVENIDA = "¡Hola! 👋 Soy el asistente del *Taller Beldor*. Te ayudo a sacar un turno.";
// ── Entrada principal ──
export async function procesarMensaje(telefonoRaw: string, textoRaw: string): Promise<RespuestaBot[]> {
const telefono = normalizarTelefono(telefonoRaw);
const texto = (textoRaw ?? "").trim();
const lower = texto.toLowerCase();
const conv = await prisma.conversacionWhatsapp.upsert({
where: { telefono },
create: { telefono },
update: {},
});
let paso = conv.paso;
let datos = (conv.datos ?? {}) as DatosConv;
// Expiración: si la charla quedó a mitad y pasó el tiempo, se reinicia.
const vencida = Date.now() - new Date(conv.updatedAt).getTime() > EXPIRA_MIN * 60 * 1000;
if (paso !== "INICIO" && vencida) {
paso = "INICIO";
datos = {};
}
// Comando global: cancelar
if (NO.includes(lower) && paso !== "CONFIRMAR" && paso !== "INICIO") {
return guardar(conv.id, "INICIO", {}, [
{ tipo: "texto", texto: "Listo, cancelé la operación. Escribime cuando quieras para empezar de nuevo." },
]);
}
switch (paso) {
case "INICIO": {
const cliente = await prisma.cliente.findFirst({ where: { telefonoNorm: telefono } });
if (cliente) {
if (cliente.email) {
return guardar(conv.id, "VERIFICAR_EMAIL", { intentosEmail: 0 }, [
{ tipo: "texto", texto: BIENVENIDA },
{ tipo: "texto", texto: "Para confirmar tu identidad, escribí el *email* que tenés registrado." },
], cliente.id);
}
// Cliente sin email registrado → no hay con qué verificar, se avanza directo.
const m = await pasoElegirMecanico();
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: BIENVENIDA }, ...m.resp], cliente.id);
}
// Cliente nuevo → alta.
return guardar(conv.id, "ALTA_NOMBRE", {}, [
{ tipo: "texto", texto: BIENVENIDA },
{ tipo: "texto", texto: "No te tengo registrado. ¿Cuál es tu *nombre*?" },
], null);
}
case "VERIFICAR_EMAIL": {
const cliente = conv.clienteId
? await prisma.cliente.findUnique({ where: { id: conv.clienteId } })
: await prisma.cliente.findFirst({ where: { telefonoNorm: telefono } });
if (cliente?.email && lower === cliente.email.toLowerCase()) {
const m = await pasoElegirMecanico();
return guardar(conv.id, m.paso, m.datos, m.resp, cliente.id);
}
const intentos = (datos.intentosEmail ?? 0) + 1;
if (intentos >= 3)
return guardar(conv.id, "INICIO", {}, [
{ tipo: "texto", texto: "El email no coincide. Por seguridad cerramos acá; comunicate con el taller. 🙏" },
]);
return guardar(conv.id, "VERIFICAR_EMAIL", { ...datos, intentosEmail: intentos }, [
{ tipo: "texto", texto: "Ese email no coincide con el registrado. Probá de nuevo." },
]);
}
case "ALTA_NOMBRE": {
if (!texto) return guardar(conv.id, "ALTA_NOMBRE", datos, [{ tipo: "texto", texto: "Decime tu *nombre*, por favor." }]);
return guardar(conv.id, "ALTA_APELLIDO", { ...datos, nombre: texto }, [
{ tipo: "texto", texto: `Gracias, ${texto}. ¿Cuál es tu *apellido*?` },
]);
}
case "ALTA_APELLIDO": {
if (!texto) return guardar(conv.id, "ALTA_APELLIDO", datos, [{ tipo: "texto", texto: "Decime tu *apellido*, por favor." }]);
return guardar(conv.id, "ALTA_EMAIL", { ...datos, apellido: texto }, [
{ tipo: "texto", texto: "Por último, ¿cuál es tu *email*?" },
]);
}
case "ALTA_EMAIL": {
if (!EMAIL_RE.test(texto))
return guardar(conv.id, "ALTA_EMAIL", datos, [{ tipo: "texto", texto: "Ese email no parece válido. Escribilo de nuevo." }]);
const existente = await prisma.cliente.findUnique({ where: { email: texto.toLowerCase() } });
if (existente)
return guardar(conv.id, "ALTA_EMAIL", datos, [
{ tipo: "texto", texto: "Ese email ya está registrado con otro número. Escribí otro o comunicate con el taller." },
]);
const cliente = await prisma.cliente.create({
data: {
nombre: datos.nombre ?? "",
apellido: datos.apellido ?? "",
email: texto.toLowerCase(),
telefono,
telefonoNorm: telefono,
},
});
const m = await pasoElegirMecanico();
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "¡Listo, quedaste registrado! ✅" }, ...m.resp], cliente.id);
}
case "ELEGIR_MECANICO": {
const op = elegirOpcion(datos, texto);
if (!op) {
const m = await pasoElegirMecanico();
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "No entendí. Elegí un mecánico:" }, ...m.resp]);
}
const m = await pasoElegirTrabajo({ jefeMecanicoId: op.id, jefeNombre: op.label });
return guardar(conv.id, m.paso, m.datos, m.resp);
}
case "ELEGIR_TRABAJO": {
const op = elegirOpcion(datos, texto);
if (!op)
return guardar(conv.id, "ELEGIR_TRABAJO", datos, [{ tipo: "texto", texto: "No entendí. Respondé con la letra del trabajo." }]);
const servicio = await prisma.tipoServicio.findUnique({ where: { id: op.id } });
if (!servicio || !servicio.activo || !trabajoEsDeJefe(servicio, datos.jefeMecanicoId!)) {
const m = await pasoElegirTrabajo(datos);
return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "Ese trabajo ya no está disponible." }, ...m.resp]);
}
const nuevosDatos: DatosConv = {
jefeMecanicoId: datos.jefeMecanicoId,
jefeNombre: datos.jefeNombre,
tipoServicioId: servicio.id,
tipoNombre: servicio.nombre,
duracionHoras: servicio.duracionHoras,
};
const m = await pasoMostrarHorarios(nuevosDatos, 0);
return guardar(conv.id, m.paso, m.datos, m.resp);
}
case "ELEGIR_HORARIO": {
if (MAS.includes(lower)) {
const m = await pasoMostrarHorarios(datos, (datos.slotsOffset ?? 0) + SLOTS_POR_PAGINA);
return guardar(conv.id, m.paso, m.datos, m.resp);
}
const op = elegirOpcion(datos, texto);
if (!op)
return guardar(conv.id, "ELEGIR_HORARIO", datos, [
{ tipo: "texto", texto: "No entendí. Respondé con la letra del horario (o *más* para ver otros)." },
]);
const resumen = `Confirmás este turno?\n\n🔧 Mecánico: ${datos.jefeNombre}\n🛠️ Trabajo: ${datos.tipoNombre}\n📅 ${op.label}\n\nRespondé *sí* o *no*.`;
return guardar(conv.id, "CONFIRMAR", { ...datos, slotInicio: op.id, slotLabel: op.label }, [
{ tipo: "texto", texto: resumen },
]);
}
case "CONFIRMAR": {
if (NO.includes(lower))
return guardar(conv.id, "INICIO", {}, [{ tipo: "texto", texto: "Listo, no reservé nada. Escribime cuando quieras. 👋" }]);
if (!SI.includes(lower))
return guardar(conv.id, "CONFIRMAR", datos, [{ tipo: "texto", texto: "Respondé *sí* para confirmar o *no* para cancelar." }]);
const res = await crearTurno({
clienteId: conv.clienteId!,
jefeMecanicoId: datos.jefeMecanicoId!,
tipoServicioId: datos.tipoServicioId!,
fechaInicio: new Date(datos.slotInicio!),
});
if (!res.ok) {
if (res.status === 409) {
const m = await pasoMostrarHorarios(datos, 0);
return guardar(conv.id, m.paso, m.datos, [
{ tipo: "texto", texto: "Justo te ganaron ese horario. 😕 Te muestro otros:" },
...m.resp,
]);
}
return guardar(conv.id, "INICIO", {}, [{ tipo: "texto", texto: `No pude reservar: ${res.error}. Probá de nuevo más tarde.` }]);
}
return guardar(conv.id, "INICIO", {}, [
{
tipo: "texto",
texto: `¡Turno confirmado! ✅\n\n🔧 ${datos.jefeNombre}\n🛠️ ${datos.tipoNombre}\n📅 ${datos.slotLabel}\n\nTe esperamos. Si querés otro turno, escribime.`,
},
]);
}
default:
return guardar(conv.id, "INICIO", {}, [{ tipo: "texto", texto: BIENVENIDA }]);
}
}
+181
View File
@@ -0,0 +1,181 @@
import crypto from "crypto";
import type { RespuestaBot } from "./whatsapp-bot";
/**
* Cliente de la WhatsApp Business Cloud API (Graph API de Meta).
* Traduce las `RespuestaBot` que emite la máquina de conversación a los payloads
* que espera Meta. El bot no sabe nada de Meta: todo el acople vive acá.
*/
const GRAPH_VERSION = "v21.0";
// Límites de la API de Meta — si nos pasamos, el mensaje se rechaza entero.
const MAX_TEXTO = 4096;
const MAX_BODY_INTERACTIVO = 1024;
const MAX_BOTONES = 3;
const MAX_TITULO_BOTON = 20;
function credenciales() {
const token = process.env.WHATSAPP_TOKEN;
const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID;
if (!token || !phoneNumberId) return null;
return { token, phoneNumberId };
}
export function whatsappConfigurado(): boolean {
return credenciales() !== null;
}
/** Valida la firma X-Hub-Signature-256 del webhook contra el cuerpo crudo. */
export function firmaValida(cuerpoCrudo: string, firmaHeader: string | null): boolean {
const appSecret = process.env.WHATSAPP_APP_SECRET;
if (!appSecret) return false;
if (!firmaHeader?.startsWith("sha256=")) return false;
const esperada = crypto.createHmac("sha256", appSecret).update(cuerpoCrudo, "utf8").digest("hex");
const recibida = firmaHeader.slice("sha256=".length);
// Comparación de tiempo constante; timingSafeEqual explota si difieren los largos.
const a = Buffer.from(esperada, "utf8");
const b = Buffer.from(recibida, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Meta tira 429 (rate limit) y 5xx transitorios; ahí conviene reintentar en vez de
// perder la respuesta al cliente. Un 4xx que no sea 429 es error nuestro: no se
// reintenta porque va a fallar igual.
const INTENTOS = 3;
const ESPERA_BASE_MS = 500;
function esperar(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function postMensaje(payload: Record<string, unknown>): Promise<void> {
const cred = credenciales();
if (!cred) {
console.error("[whatsapp] falta WHATSAPP_TOKEN o WHATSAPP_PHONE_NUMBER_ID; no se envía nada");
return;
}
const url = `https://graph.facebook.com/${GRAPH_VERSION}/${cred.phoneNumberId}/messages`;
const init: RequestInit = {
method: "POST",
headers: {
Authorization: `Bearer ${cred.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ messaging_product: "whatsapp", ...payload }),
};
for (let intento = 1; intento <= INTENTOS; intento++) {
let status: number;
let detalle = "";
try {
const res = await fetch(url, init);
if (res.ok) return;
status = res.status;
detalle = await res.text().catch(() => "");
} catch (e) {
// Falla de red: mismo tratamiento que un 5xx.
status = 0;
detalle = String(e);
}
const reintentable = status === 0 || status === 429 || status >= 500;
if (!reintentable || intento === INTENTOS) {
console.error(`[whatsapp] error ${status} al enviar mensaje (intento ${intento}): ${detalle}`);
return;
}
// Backoff exponencial con jitter para no sincronizar los reintentos.
const espera = ESPERA_BASE_MS * 2 ** (intento - 1) + Math.floor(Math.random() * 250);
console.warn(`[whatsapp] error ${status}, reintento ${intento + 1}/${INTENTOS} en ${espera}ms`);
await esperar(espera);
}
}
async function enviarTexto(telefono: string, texto: string): Promise<void> {
await postMensaje({
to: telefono,
type: "text",
text: { body: texto.slice(0, MAX_TEXTO), preview_url: false },
});
}
async function enviarBotones(
telefono: string,
texto: string,
botones: { id: string; titulo: string }[]
): Promise<void> {
// Meta banca 3 botones como máximo. La máquina de conversación siempre deja las
// mismas opciones disponibles por número/letra, así que recortar no rompe el flujo.
const recortados = botones.slice(0, MAX_BOTONES).map((b) => ({
type: "reply",
reply: { id: b.id, title: b.titulo.slice(0, MAX_TITULO_BOTON) },
}));
if (recortados.length === 0) {
await enviarTexto(telefono, texto);
return;
}
await postMensaje({
to: telefono,
type: "interactive",
interactive: {
type: "button",
body: { text: texto.slice(0, MAX_BODY_INTERACTIVO) },
action: { buttons: recortados },
},
});
}
/** Envía en orden las respuestas del bot (secuencial: WhatsApp respeta el orden de llegada). */
export async function enviarRespuestas(telefono: string, respuestas: RespuestaBot[]): Promise<void> {
for (const r of respuestas) {
if (r.tipo === "botones") await enviarBotones(telefono, r.texto, r.botones);
else await enviarTexto(telefono, r.texto);
}
}
/** Marca el mensaje como leído (los tildes azules). Best-effort: si falla, no importa. */
export async function marcarLeido(messageId: string): Promise<void> {
await postMensaje({ status: "read", message_id: messageId });
}
// ── Entrada: payload del webhook → mensajes ──
// Forma (parcial) de lo que manda Meta; solo lo que consumimos.
export type MensajeMeta = {
from?: string;
id?: string;
type?: string;
text?: { body?: string };
button?: { text?: string };
interactive?: { button_reply?: { id?: string }; list_reply?: { id?: string } };
};
export type PayloadWebhook = {
entry?: { changes?: { value?: { messages?: MensajeMeta[] } }[] }[];
};
/** Aplana el payload del webhook a la lista de mensajes entrantes (ignora `statuses`). */
export function mensajesDelPayload(body: PayloadWebhook): MensajeMeta[] {
const salida: MensajeMeta[] = [];
for (const entry of body?.entry ?? [])
for (const change of entry?.changes ?? []) salida.push(...(change?.value?.messages ?? []));
return salida;
}
/** Saca el texto de un mensaje según su tipo. `null` = tipo que el bot no maneja. */
export function textoDeMensaje(mensaje: MensajeMeta): string | null {
if (mensaje.type === "text") return mensaje.text?.body ?? "";
if (mensaje.type === "interactive") {
const i = mensaje.interactive ?? {};
// El id del botón/opción es el que puso el bot en las opciones → matchea directo.
return i.button_reply?.id ?? i.list_reply?.id ?? null;
}
if (mensaje.type === "button") return mensaje.button?.text ?? null;
return null;
}
+15 -6
View File
@@ -3,12 +3,14 @@ import { jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(process.env.NEXTAUTH_SECRET || "dev-secret"); const SECRET = new TextEncoder().encode(process.env.NEXTAUTH_SECRET || "dev-secret");
async function verifyCookie(req: NextRequest, cookieName: string) { // Las tres sesiones se firman con el mismo secreto, así que además de la firma hay
// que mirar el rol: si no, un token de jefe en la cookie del cliente pasa.
async function verifyCookie(req: NextRequest, cookieName: string, rol: "admin" | "cliente" | "jefe") {
const token = req.cookies.get(cookieName)?.value; const token = req.cookies.get(cookieName)?.value;
if (!token) return false; if (!token) return false;
try { try {
await jwtVerify(token, SECRET); const { payload } = await jwtVerify(token, SECRET);
return true; return (payload as { role?: string }).role === rol;
} catch { } catch {
return false; return false;
} }
@@ -19,8 +21,9 @@ export async function middleware(req: NextRequest) {
if (pathname.startsWith("/cliente/dashboard") || if (pathname.startsWith("/cliente/dashboard") ||
pathname.startsWith("/cliente/historial") || pathname.startsWith("/cliente/historial") ||
pathname.startsWith("/cliente/perfil") ||
pathname.startsWith("/cliente/turnos")) { pathname.startsWith("/cliente/turnos")) {
const ok = await verifyCookie(req, "cliente_session"); const ok = await verifyCookie(req, "cliente_session", "cliente");
if (!ok) return NextResponse.redirect(new URL("/cliente", req.url)); if (!ok) return NextResponse.redirect(new URL("/cliente", req.url));
} }
@@ -28,14 +31,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", "admin");
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", "jefe");
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*"],
}; };