Compare commits

...

6 Commits

Author SHA1 Message Date
google-labs-jules[bot]
012f45f451 docs: Add code quality analysis in weak_points.md
This commit introduces a new markdown file, `weak_points.md`, to document the findings of a quality assurance review of the AnchorOS codebase.

The analysis identifies several key areas for improvement:
- Outdated and missing dependencies.
- Complete absence of an automated testing strategy.
- Poorly maintained and insecure custom scripts.
- A "God Component" in the main dashboard (`app/aperture/page.tsx`).
- Significant technical debt, including legacy code and numerous `TODO` comments.

Each identified issue includes a detailed explanation of the associated risks and actionable recommendations for improvement.
2026-01-24 00:40:19 +00:00
Marco Gallegos
d27354fd5a feat: Add kiosk management, artist selection, and schedule management
- Add KiosksManagement component with full CRUD for kiosks
- Add ScheduleManagement for staff schedules with break reminders
- Update booking flow to allow artist selection by customers
- Add staff_services API for assigning services to artists
- Update staff management UI with service assignment dialog
- Add auto-break reminder when schedule >= 8 hours
- Update availability API to filter artists by service
- Add kiosk management to Aperture dashboard
- Clean up ralphy artifacts and logs
2026-01-21 13:02:06 -06:00
Marco Gallegos
24e5af3860 Update brand kit with comprehensive test links and route descriptions 2026-01-20 12:35:52 -06:00
Marco Gallegos
bff1edf04f Add brand kit manual with test links 2026-01-20 12:33:54 -06:00
Marco Gallegos
ef3d5f421a docs: Update PRD.md to reflect current project status
- Mark completed tasks across all phases (1-6)
- Add technology stack documentation
- Document system architecture (multi-domain)
- Detail implemented features (kiosk, payments, dashboard)
- Update project status to 95% completion
- Add remaining work and future phases
- Expand membership tiers (Free, Gold, Black, VIP)
- Add Kiosk role to hierarchy
- Enhance payments section with implementation details
2026-01-19 10:33:51 -06:00
Marco Gallegos
68dfe54fd2 feat: Add Ralphy automation script and initialize project config
- Add ralphy.sh: Autonomous AI coding loop supporting multiple engines
- Initialize .ralphy/ config directory for project automation
- Update PRD.md with task list for AnchorOS development
2026-01-19 10:23:46 -06:00
72 changed files with 3880 additions and 1012 deletions

17
.env.js
View File

@@ -1,17 +0,0 @@
NEXT_PUBLIC_SUPABASE_URL=https://pvvwbnybkadhreuqijsl.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg0OTk1MzksImV4cCI6MjA4NDA3NTUzOX0.298akX41SawJiJ0OovDK3FbEnbWJwEnhYlU08mbw9Sk
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2ODQ5OTUzOSwiZXhwIjoyMDg0MDc1NTM5fQ.bEkwIvPfsa4ZQRqyOkdtE-3PLailNSIz4XRKJJJrtpg
NEXT_PUBLIC_STRIPE_ENABLED=false
STRIPE_SECRET_KEY=REDACTED_SERVER_ONLY
STRIPE_PUBLISHABLE_KEY=pk_live_51N8FdAB4PJM8J9HnOkKyviAySjVXYjJqca9vWoy0jTU1aT56CtxD0dmT5eszAg40egvtGoWklLfbPadrbnNpIO8P00yHyXPPuT
STRIPE_WEBHOOK_SECRET=REDACTED_SERVER_ONLY
GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"..."}
GOOGLE_CALENDAR_ID=primary
TWILIO_ACCOUNT_SID=REDACTED_SERVER_ONLY
TWILIO_AUTH_TOKEN=REDACTED_SERVER_ONLY
TWILIO_WHATSAPP_FROM=whatsapp:+14155238886
NEXTAUTH_URL=https://anchoros.soul23.cloud
NEXTAUTH_SECRET=ODB6oloFvaGgNaM5s2tINGPryU9YHlxivDGQYT+0O7M=
NEXT_PUBLIC_APP_URL=https://anchoros.soul23.cloud
ADMIN_ENROLLMENT_KEY=REDACTED_SERVER_ONLY
NEXT_PUBLIC_KIOSK_API_KEY=FIGe1OWhv6awCABwK9SecbiSy2vOjJuXKAzJsAsRQLZnwm9RbOEEjrtYVGBj1oST

40
.env.template Normal file
View File

@@ -0,0 +1,40 @@
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_URL=your_supabase_project_url
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# Stripe Configuration
NEXT_PUBLIC_STRIPE_ENABLED=false
STRIPE_SECRET_KEY=your_stripe_secret_key
STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
# Google Calendar (Optional)
GOOGLE_SERVICE_ACCOUNT_JSON=your_google_service_account_json
GOOGLE_CALENDAR_ID=primary
GOOGLE_CALENDAR_VERIFY_TOKEN=your_verify_token
# WhatsApp/Twilio (Optional)
TWILIO_ACCOUNT_SID=your_twilio_account_sid
TWILIO_AUTH_TOKEN=your_twilio_auth_token
TWILIO_WHATSAPP_FROM=whatsapp:+your_twilio_whatsapp_number
# Email (Optional)
RESEND_API_KEY=your_resend_api_key
# Application
NEXT_PUBLIC_APP_URL=http://localhost:2311
# Admin Enrollment (Optional)
ADMIN_ENROLLMENT_KEY=your_admin_enrollment_key
# Cron Jobs
CRON_SECRET=your_cron_secret
# Kiosk (Optional)
NEXT_PUBLIC_KIOSK_API_KEY=your_kiosk_api_key
# Formbricks (Optional)
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=your_formbricks_environment_id
NEXT_PUBLIC_FORMBRICKS_API_HOST=https://app.formbricks.com

22
.gitignore vendored
View File

@@ -35,3 +35,25 @@ next-env.d.ts
# supabase
.supabase/
# ralphy
ralphy.sh
# Additional security - protect all env files
.env*
!.env.example
!.env.template
# Temporary files
*.tmp
*.bak
*.old
# Logs
*.log
dev.log
server.log
# Build artifacts
.next/
tsconfig.tsbuildinfo

238
Brand_Kit.md Normal file
View File

@@ -0,0 +1,238 @@
# ANCHOR:23
---
## 1. Origen de la Marca
Anchor:23 nace de la unión de **dos creativos** con trayectorias distintas y un criterio común: el lujo no es promesa, es estándar.
La marca surge como respuesta a una ausencia clara en la ciudad: un salón que opere bajo reglas de ultra lujo real, con ejecución constante, acceso limitado y una experiencia coherente en cada detalle.
No es una extensión de otra marca. No es una evolución emocional. Es un concepto paralelo, deliberadamente selectivo.
---
## 2. Significado del Nombre
### Anchor
Anchor representa el punto fijo. La base que sostiene y da estabilidad.
En la marca simboliza el estándar bajo el cual se ejecuta cada servicio, decisión y experiencia. No como rigidez, sino como referencia clara.
Es estructura. No ornamento.
### El signo (:)
El signo funciona como una **articulación**.
Ordena el nombre y permite la convivencia de dos criterios creativos dentro de un mismo sistema. No busca significado simbólico ni lectura emocional.
No se explica. No se enfatiza.
Comunica estructura.
### El número 23
El 23 es un **código interno**.
Remite a una idea de dirección, cuidado y constancia entendida de forma cultural y personal, no declarativa. No se presenta como mensaje ni como símbolo explícito.
No se comunica hacia afuera. Opera como fundamento silencioso del concepto.
El cliente no debe entenderlo.
Debe percibirlo en la experiencia: continuidad, calma y seguridad.
---
## 3. Categoría
Belleza de ultra lujo.
Anchor:23 opera como un **concepto exclusivo**, no masivo, con un estándar de servicio que no existe en el mercado local.
---
## 4. Propósito
Ofrecer una experiencia estética exclusiva basada en precisión técnica, coherencia visual y ejecución constante.
---
## 5. Visión
Ser el referente local de belleza ultra exclusiva, reconocido por su nivel de servicio, selección rigurosa y consistencia impecable.
---
## 6. Misión
Operar un concepto de salón de ultra lujo con **una sola sucursal por ciudad**, ajustada al tamaño del mercado, para preservar exclusividad, estándar y coherencia de experiencia.
Anchor:23 no escala por volumen. Escala por selección.
---
## 7. Valores
* Exclusividad — El acceso es limitado por diseño.
* Excelencia — El estándar es alto y sostenido.
* Selección — Clientes y equipo cumplen criterios claros.
* Sobriedad — El lujo se expresa con medida.
* Consistencia — La experiencia es siempre la misma.
---
## 8. Personalidad de Marca
* Sobria
* Precisa
* Selectiva
* Elegante
* Reservada
Anchor:23 no busca agradar a todos.
---
## 9. Arquetipo
**El Curador**
Selecciona, eleva estándares y protege la experiencia.
---
## 10. Voz y Tono
### Voz
* Clara
* Breve
* Profesional
### Tono
* Seguro
* Reservado
* Elegante
Sin adornos. Sin exageraciones.
---
## 11. Identidad Visual
### Principios
* Geometría clara
* Centro de gravedad estable
* Amplio espacio negativo
* Composición silenciosa
Nunca gestual. Nunca decorativa.
---
## 12. Paleta de Color (GitHub Compatible)
| Swatch | Nombre | Hex |
| ------------------------------------------------------------ | -------------- | --------- |
| ![Bone White](assets/colors/bone-white.png) | Bone White | `#F6F1EC` |
| ![Soft Cream](assets/colors/soft-cream.png) | Soft Cream | `#EFE7DE` |
| ![Mocha Taupe](assets/colors/mocha-taupe.png) | Mocha Taupe | `#B8A89A` |
| ![Deep Earth](assets/colors/deep-earth.png) | Deep Earth | `#6F5E4F` |
| ![Charcoal Brown](assets/colors/charcoal-brown.png) | Charcoal Brown | `#3F362E` |
Uso contenido. Sin saturación. Sin gradientes.
---
## 13. Tipografía
### Headings
Serif editorial sobria.
### Texto y UI
Sans neutral.
Mucho aire. Jerarquía estricta.
---
## 14. Experiencia de Marca
Anchor:23 se vive como:
* Acceso limitado
* Atención altamente profesional
* Protocolos definidos
* Ambiente sobrio y refinado
La experiencia no se negocia.
---
## 15. Presencia Digital
### anchor23.mx
Sitio institucional. Marca, narrativa y conversión inicial.
### booking.anchor23.mx
Sistema de reservas (The Boutique).
### kiosk.anchor23.mx
Sistema táctil en sucursal (The Kiosk).
---
## 16. Principio Rector
La exclusividad no se declara.
Se demuestra en cada detalle.
---
## 17. Links de Prueba
### Frontend Institucional (anchor23.mx)
- https://anchoros.soul23.cloud/ - Landing page con hero, fundamento, servicios y testimoniales.
- https://anchoros.soul23.cloud/servicios - Página de servicios con descripciones.
- https://anchoros.soul23.cloud/historia - Historia y filosofía de la marca.
- https://anchoros.soul23.cloud/contacto - Formulario de contacto.
- https://anchoros.soul23.cloud/franchises - Información de franquicias.
- https://anchoros.soul23.cloud/membresias - Membresías (Gold, Black, VIP).
### The Boutique (booking.anchor23.mx)
- https://anchoros.soul23.cloud/booking/servicios - Selección de servicios y calendario de disponibilidad.
- https://anchoros.soul23.cloud/booking/cita - Flujo de reserva en pasos (búsqueda cliente, confirmación, pago).
- https://anchoros.soul23.cloud/booking/confirmacion - Confirmación de reserva por código.
- https://anchoros.soul23.cloud/booking/registro - Registro de nuevos clientes.
- https://anchoros.soul23.cloud/booking/login - Login con magic links.
- https://anchoros.soul23.cloud/booking/perfil - Perfil de cliente con historial.
- https://anchoros.soul23.cloud/booking/mis-citas - Gestión de citas del cliente.
### The HQ (aperture.anchor23.mx)
- https://anchoros.soul23.cloud/aperture - Dashboard home con KPIs, top performers y feed de actividad.
- https://anchoros.soul23.cloud/aperture/calendar - Calendario maestro con drag & drop y filtros.
- https://anchoros.soul23.cloud/aperture/staff - Gestión de staff (CRUD, comisiones, nómina).
- https://anchoros.soul23.cloud/aperture/clients - CRM de clientes con fidelización.
- https://anchoros.soul23.cloud/aperture/pos - Punto de venta y cierre de caja.
- https://anchoros.soul23.cloud/aperture/finance - Finanzas y reportes.
### The Kiosk (kiosk.anchor23.mx)
- https://anchoros.soul23.cloud/kiosk/[locationId] - Sistema táctil para confirmación de citas y walk-ins.
### Página Centralizada de Test Links
- https://anchoros.soul23.cloud/testlinks - Directorio completo de todas las páginas y APIs del proyecto.
---
Fin del manual de marca Anchor:23.

203
PRD.md
View File

@@ -24,6 +24,8 @@ AnchorOS es un sistema operativo para salones de belleza orientado a agenda, pag
* Free
* Gold
* Black
* VIP
### 3.2 Tier Gold — Beneficios
@@ -50,6 +52,7 @@ AnchorOS es un sistema operativo para salones de belleza orientado a agenda, pag
* **Manager**: Acceso operacional. Puede ver PII de clientes y hacer ajustes.
* **Staff**: Nivel de coordinación. Puede ver PII de clientes y hacer ajustes.
* **Artist**: Nivel de ejecución. **Solo puede ver nombre y notas** del cliente. No ve email ni phone.
* **Kiosk**: Acceso limitado para dispositivos táctiles. No puede acceder a PII de clientes.
* **Customer**: Nivel más bajo. Solo puede ver sus propios datos.
---
@@ -92,9 +95,12 @@ AnchorOS es un sistema operativo para salones de belleza orientado a agenda, pag
## 6. Pagos
* Stripe como proveedor principal.
* El Short ID se utiliza como referencia visible.
* UUID se mantiene interno.
* Stripe como proveedor principal con webhooks para eventos de pago.
* El Short ID se utiliza como referencia visible para clientes.
* UUID se mantiene interno para integridad de datos.
* Lógica de depósitos dinámicos: $200 fijo vs 50% del servicio según timing.
* Sistema automático de penalizaciones por no-show con posibilidad de waivers.
* Soporte para múltiples métodos de pago en POS (efectivo, tarjeta, transferencias, giftcards, membresías).
---
@@ -118,4 +124,193 @@ AnchorOS es un sistema operativo para salones de belleza orientado a agenda, pag
## 9. Estado del Documento
Este PRD es la fuente única de verdad funcional del sistema AnchorOS.
Este PRD es la fuente única de verdad funcional del sistema AnchorOS y refleja el estado actual de implementación.
---
## 10. Tecnologías Utilizadas
### Frontend
- **Next.js 14** (App Router) con React 18 y TypeScript
- **Tailwind CSS** para estilos
- **Radix UI** para componentes accesibles
- **Framer Motion** para animaciones
- **React Hook Form + Zod** para validación de formularios
- **date-fns + date-fns-tz** para manejo de fechas
- **DnD Kit** para drag & drop
### Backend e Infraestructura
- **Supabase** (PostgreSQL + Auth + RLS + Storage)
- **Stripe** para procesamiento de pagos
- **Google APIs** para integración de calendario
- **Resend** para envío de emails
- **Formbricks** para feedback de usuarios
### Desarrollo
- **ESLint** para linting
- **PostCSS + Autoprefixer** para CSS
- **html2canvas + jsPDF** para generación de PDFs
---
## 11. Arquitectura del Sistema
AnchorOS implementa una arquitectura multi-dominio para separación clara de responsabilidades:
- **anchor23.mx**: Portal administrativo principal
- **booking.anchor23.mx**: Sistema de reservas públicas
- **aperture.anchor23.mx**: Dashboard operativo (Aperture HQ)
- **kiosk.anchor23.mx**: Sistema de quioscos táctiles
### Base de Datos
- **15+ tablas** con relaciones normalizadas
- **RLS policies** estrictas para control de acceso
- **UUIDs primarios** con Short IDs para referencias humanas
- **Auditoría completa** en `audit_logs`
---
## 12. Funcionalidades Implementadas
### Sistema de Quioscos
- Autenticación por API keys de 64 caracteres
- Creación de reservas walk-in con asignación inteligente
- Interfaz touch-friendly optimizada
- Restricciones de PII (no acceso a datos personales)
### Motor de Disponibilidad
- Asignación prioritaria: makeup > lashes > pedicure > manicure
- Detección de conflictos de recursos
- Soporte para servicios duales
- Sincronización con Google Calendar
### Gestión de Membresías Avanzada
- **Free**: Acceso básico
- **Gold**: Prioridad en agenda, 5 invitaciones semanales, beneficios financieros
- **Black**: Beneficios premium adicionales
- **VIP**: Acceso completo incluyendo galería privada
### Sistema de Pagos Completo
- Webhooks de Stripe para eventos de pago
- Lógica automática de no-shows
- Sistema de waivers para penalizaciones
- Múltiples métodos de pago en POS
### Dashboard Operativo (Aperture HQ)
- KPIs en tiempo real (ventas, reservas, clientes)
- Calendario maestro multi-columna
- Gestión completa de staff y recursos
- Reportes financieros y operativos
---
## 13. Estado Actual del Proyecto
**Nivel de Completitud: ~97%**
### Fortalezas
- Arquitectura sólida con separación clara de dominios
- Seguridad de primer nivel con RLS y auditoría completa
- Núcleo listo para producción (pagos, reservas, dashboards)
- Diseño escalable con soporte multi-ubicación
- Documentación exhaustiva (80+ archivos con JSDoc)
### Calidad Técnica
- Código bien estructurado con TypeScript
- Pruebas automatizadas en proceso
- Integraciones robustas (Stripe, Google Calendar)
- UI/UX optimizada para diferentes dispositivos
---
## 14. Trabajo Pendiente (3%)
### Mejoras Opcionales en Calendar Maestro
- Redimensionamiento de bloques (drag en el borde inferior)
- Vistas semanales/mensuales adicionales
### The Vault (Opcional)
- Almacenamiento privado de fotos para clientes VIP
### Transferencias Cross-Location (Opcional)
- Movimiento de staff entre ubicaciones
---
## 15. Fases Futuras
### Fase 7: Automatización y Lanzamiento
- Notificaciones WhatsApp (confirmaciones, recordatorios, no-shows)
- Recibos digitales por email
- Landing page pública para adquisición de clientes
- Optimización SEO (robots.txt, sitemap.xml)
### Fase 8: Características Avanzadas
- Sincronización completa de Google Calendar
- Campañas de marketing (emails/WhatsApp masivos)
- Precios dinámicos basados en tiempo
- Integraciones externas (Instagram/Facebook shopping)
---
## 16. Validación y Testing
### Pruebas Unitarias
- Generador de Short IDs
- Funciones de disponibilidad
- Lógica de asignación de recursos
### Pruebas de Integración
- Flujos completos de reserva
- Procesamiento de pagos
- Sincronización de calendario
### Validación en Producción
- Testing de migración en entorno live
- Validación de rendimiento con carga real
---
## 17. Roadmap de Desarrollo
### Fase 1: Infraestructura Core ✅
- [x] Configurar estructura del proyecto con timestamps UTC en backend
- [x] Implementar UUID como claves primarias para todas las entidades
- [x] Agregar generación de Short ID con verificación de unicidad
- [x] Crear control de acceso basado en roles (Admin, Manager, Staff, Artist, Customer, Kiosk)
- [x] Implementar manejo de zonas horarias (UTC en backend, local en frontend)
- [x] Agregar logging de auditoría para acciones automáticas
### Fase 2: Sistema de Bookings y Agenda ✅
- [x] Construir sistema de bookings con funcionalidad de agenda
- [x] Implementar motor de disponibilidad con asignación inteligente de recursos
- [x] Integrar Google Calendar para sincronización bidireccional
- [x] Soporte para servicios de doble capacidad (2 artistas)
### Fase 3: Sistema de Pagos ✅
- [x] Integrar pagos con Stripe usando short ID como referencia
- [x] Implementar lógica de depósitos dinámicos ($200 vs 50%)
- [x] Sistema de penalizaciones por no-show con waivers
### Fase 4: Dashboard Aperture HQ (100% completado)
- [x] Dashboard principal con KPIs y métricas operativas
- [x] Calendar Maestro con vista multi-columna y drag & drop
- [x] Gestión de staff y recursos (CRUD completo)
- [x] Sistema de comisiones y nómina
- [x] Reportes diarios de cierre (PDF)
- [x] Creación de citas desde slots vacíos en calendario
- [ ] Mejoras opcionales en calendario (resize de bloques, vista semanal/mensual)
### Fase 5: Gestión de Clientes y Lealtad ✅
- [x] Crear niveles de membresía (Free, Gold, Black, VIP) con beneficios
- [x] Sistema CRM con búsqueda fonética y notas técnicas
- [x] Implementar sistema de invitaciones para tier Gold (5 semanales, reseteables)
- [x] Sistema de puntos de lealtad independientes de tiers
- [x] Galería de fotos restringida a tiers premium
### Fase 6: Finanzas y Reportes ✅
- [x] Sistema POS con múltiples métodos de pago
- [x] Reportes de rendimiento por staff
- [x] Seguimiento de gastos operativos
- [x] Analytics financieros (ingresos, gastos, utilidades)

View File

@@ -238,7 +238,7 @@ npm install
3. Configurar variables de entorno
* Crear `.env.local`.
* Copiar `.env.template` a `.env.local` y configurar las variables requeridas.
4. Levantar entorno local
@@ -266,7 +266,7 @@ El sitio estará disponible en **http://localhost:2311**
- **FASE 1**: 100% ✅ Completada
- **FASE 2**: 100% ✅ Completada
- **FASE 3**: 100% ✅ Completada
- **FASE 4**: 95% ✅ En Progreso
- **FASE 4**: 100% ✅ COMPLETADA
- **FASE 5**: 100% ✅ Completada
- **FASE 6**: 100% ✅ Completada
- **FASE 7**: 5% ⏳ Pendiente
@@ -357,9 +357,19 @@ El sitio estará disponible en **http://localhost:2311**
-**Documentación de Correcciones**: Documento completo con detalles técnicos
- docs/RECENT_FIXES_JAN_2026.md con análisis de problemas y soluciones
- Ejemplos de código antes/después
- Validación y testing notes
- Commit: `88ea79f`
-**Test Links Page**: Página centralizada con enlaces a todas las páginas y APIs del proyecto
- Validación y testing notes
- Commit: `88ea79f`
-**Calendario Aperture - Creación de Citas**: Nueva funcionalidad de crear citas desde slots vacíos
- Click en slot vacío abre modal de creación de cita
- Selección de cliente, servicio, ubicación y staff
- Validación de disponibilidad antes de crear
- API: `POST /api/bookings` para creación de citas
- Actualización: 2026-01-21
-**Fix check_staff_availability**: Corrección de llamadas a funciones auxiliares
- Migración: 20260121000000_fix_staff_availability_function_calls.sql
- Parámetros corregidos para check_staff_work_hours y check_calendar_blocking
- Actualización: 2026-01-21
-**Test Links Page**: Página centralizada con enlaces a todas las páginas y APIs del proyecto
### Fase Actual
**Fase 1 — Cimientos y CRM**: 100% completado

View File

@@ -333,7 +333,7 @@ Tareas:
---
## FASE 4 — HQ Dashboard (PENDIENTE)
## FASE 4 — HQ Dashboard ✅ COMPLETADA
### 4.1 Calendario Multi-Columna ✅ COMPLETADO
* ✅ Vista por staff en columnas.
@@ -341,14 +341,18 @@ Tareas:
* ✅ Componente visual de citas con colores por estado.
* ✅ API `/api/aperture/calendar` para datos del calendario.
* ✅ API `/api/aperture/bookings/[id]/reschedule` para reprogramación.
* ✅ Filtros por staff (ubicación próximamente).
* Drag & drop para reprogramar (framework listo, lógica pendiente).
* ⏳ Validación de colisiones completa.
* ✅ Filtros por staff y ubicación.
* Drag & drop para reprogramar con validación de conflictos.
* ✅ Creación de nuevas citas desde slots vacíos con modal.
* ⏳ Resize dinámico de bloques (opcional).
* ✅ Validación de colisiones completa.
**Output:**
* Componente de calendario.
* Lógica de reprogramación.
* Validación de colisiones.
* Componente de calendario (CalendarView) con modal de creación de citas.
* Lógica de reprogramación (drag & drop).
* Validación de colisiones completa.
* ✅ Interfaz de creación de citas desde slots vacíos.
* ⏳ Resize dinámico de bloques (opcional).
---
@@ -598,19 +602,19 @@ Tareas:
### 🚧 En Progreso
- 🚧 Aperture - Backend para staff/manager/admin (aperture.anchor23.mx)
- ✅ API para obtener staff disponible (/api/aperture/staff)
- ✅ API para gestión de horarios (/api/aperture/staff/schedule)
- ✅ API para recursos (/api/aperture/resources)
- ✅ API para dashboard (/api/aperture/dashboard) - FUNCIONANDO
- ✅ API para calendario (/api/aperture/calendar) - FUNCIONANDO
- ✅ API para reprogramación (/api/aperture/bookings/[id]/reschedule) - FUNCIONANDO
- ✅ Componente CalendarioView con drag & drop framework
- ✅ Página de calendario (/aperture/calendar) - FUNCIONANDO
- ✅ Página principal de admin (/aperture)
- ❌ API para estadísticas (/api/aperture/stats) - FALTA IMPLEMENTAR
- ✅ Autenticación de admin/staff/manager (Supabase Auth completo)
- Gestión completa de staff (CRUD, horarios)
- Gestión de recursos y asignación
- ✅ API para obtener staff disponible (/api/aperture/staff)
- ✅ API para gestión de horarios (/api/aperture/staff/schedule)
- ✅ API para recursos (/api/aperture/resources)
- ✅ API para dashboard (/api/aperture/dashboard) - FUNCIONANDO
- ✅ API para calendario (/api/aperture/calendar) - FUNCIONANDO
- ✅ API para reprogramación (/api/aperture/bookings/[id]/reschedule) - FUNCIONANDO
- ✅ Componente CalendarioView con drag & drop framework
- ✅ Página de calendario (/aperture/calendar) - FUNCIONANDO
- ✅ Página principal de admin (/aperture)
- ✅ Creación de citas desde slots vacíos
- ✅ Autenticación de admin/staff/manager (Supabase Auth completo)
- Gestión completa de staff (CRUD, horarios)
- Gestión de recursos y asignación
### ⏳ Pendiente
- ✅ Implementar API pública (api.anchor23.mx) - Horarios, servicios, ubicaciones públicas
@@ -640,6 +644,29 @@ Tareas:
## CORRECCIONES RECIENTES ✅
### Calendario Aperture - Creación de Citas (Enero 21, 2026) ✅
**Nueva Funcionalidad:**
- Click en slot vacío del calendario abre modal de creación de cita
- Modal con selección de:
- Cliente (lista dropdown)
- Servicio (lista dropdown con duración y precio)
- Ubicación (lista dropdown)
- Staff (lista dropdown filtrado por ubicación)
- Notas (campo de texto opcional)
- Validación de campos obligatorios antes de enviar
- API: `POST /api/bookings` para crear nueva cita
- Calendario se actualiza automáticamente después de creación exitosa
**Archivos:**
- `components/calendar-view.tsx` - Componente con modal de creación de citas
**Backend:**
- Funciones de disponibilidad validan correctamente timezones (UTC)
- `check_staff_availability` con llamadas corregidas a funciones auxiliares
- Migración: 20260121000000_fix_staff_availability_function_calls.sql
---
### Corrección de Calendario (Enero 18, 2026) ✅
**Problema:**
- Calendario mostraba días desalineados con días de la semana
@@ -900,6 +927,23 @@ La migración de recursos eliminó todos los bookings existentes debido a CASCAD
---
### Corrección de Horarios de Disponibilidad en Booking (Enero 21, 2026) ✅
**Problema:**
- Sistema de booking solo mostraba horarios de 22:00 y 23:00 en lugar de los horarios de atención correctos (10:00-19:00)
- Función `get_detailed_availability` tenía problemas de conversión de timezone
**Solución:**
- Corregida función `check_staff_availability` para manejar correctamente los parámetros de timezone
- Actualizada función `get_detailed_availability` para convertir correctamente de hora local (Monterrey UTC-6) a UTC
- Creadas funciones auxiliares `check_staff_work_hours` y `check_calendar_blocking`
**Resultado:**
- ✅ Sistema ahora muestra horarios correctos: 10:00, 11:00, 12:00, 13:00, 14:00, 15:00, 16:00, 17:00, 18:00
- ✅ Respeta horarios de atención por día de la semana
- ✅ Maneja correctamente zonas horarias
---
## REGLA FINAL
Si una tarea no está aquí, no existe. Cualquier adición debe evaluarse contra el PRD y documentarse antes de ejecutarse.

View File

@@ -1,5 +1,14 @@
'use client'
/**
* @description Calendar management page for Aperture HQ dashboard with multi-column staff view
* @audit BUSINESS RULE: Calendar displays bookings for all staff with drag-and-drop rescheduling
* @audit SECURITY: Requires authenticated admin/manager/staff role via useAuth context
* @audit Validate: Users must be logged in to access calendar
* @audit PERFORMANCE: Auto-refreshes calendar data every 30 seconds for real-time updates
* @audit AUDIT: Calendar access and rescheduling actions logged for operational monitoring
*/
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
@@ -9,7 +18,13 @@ import { useAuth } from '@/lib/auth/context'
import CalendarView from '@/components/calendar-view'
/**
* @description Calendar page for managing appointments and scheduling
* @description Calendar page wrapper providing authenticated access to the multi-staff scheduling interface
* @returns {JSX.Element} Calendar page with header, logout button, and CalendarView component
* @audit BUSINESS RULE: Redirects to login if user is not authenticated
* @audit SECURITY: Uses useAuth to validate session before rendering calendar
* @audit Validate: Logout clears session and redirects to Aperture login page
* @audit PERFORMANCE: CalendarView handles its own data fetching and real-time updates
* @audit AUDIT: Login/logout events logged through auth context
*/
export default function CalendarPage() {
const { user, signOut } = useAuth()

View File

@@ -1,5 +1,14 @@
'use client'
/**
* @description Aperture HQ Dashboard - Central administrative interface for salon management
* @audit BUSINESS RULE: Dashboard aggregates KPIs, bookings, staff, resources, POS, and reports
* @audit SECURITY: Requires authenticated admin/manager role via useAuth context
* @audit Validate: Tab-based navigation with lazy loading of section data
* @audit PERFORMANCE: Data fetched on-demand when switching tabs
* @audit AUDIT: Dashboard access and actions logged for operational monitoring
*/
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
@@ -7,7 +16,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { StatsCard } from '@/components/ui/stats-card'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { Avatar } from '@/components/ui/avatar'
import { Calendar, Users, Clock, DollarSign, TrendingUp, LogOut, Trophy } from 'lucide-react'
import { Checkbox } from '@/components/ui/checkbox'
import { Calendar, Users, Clock, DollarSign, TrendingUp, LogOut, Trophy, Smartphone } from 'lucide-react'
import { format } from 'date-fns'
import { es } from 'date-fns/locale'
import { useAuth } from '@/lib/auth/context'
@@ -16,14 +26,23 @@ import StaffManagement from '@/components/staff-management'
import ResourcesManagement from '@/components/resources-management'
import PayrollManagement from '@/components/payroll-management'
import POSSystem from '@/components/pos-system'
import KiosksManagement from '@/components/kiosks-management'
import ScheduleManagement from '@/components/schedule-management'
/**
* @description Admin dashboard component for managing salon operations including bookings, staff, resources, reports, and permissions.
* @description Main Aperture dashboard component with tabbed navigation to different management sections
* @returns {JSX.Element} Complete dashboard interface with stats, KPI cards, activity feed, and management tabs
* @audit BUSINESS RULE: Dashboard displays real-time KPIs and allows management of all salon operations
* @audit BUSINESS RULE: Tabs include dashboard, calendar, staff, payroll, POS, resources, reports, and permissions
* @audit SECURITY: Requires authenticated admin/manager role; staff have limited access
* @audit Validate: Fetches data based on active tab to optimize initial load
* @audit PERFORMANCE: Uses StatsCard, Tables, and other optimized UI components
* @audit AUDIT: All dashboard interactions logged for operational transparency
*/
export default function ApertureDashboard() {
const { user, signOut } = useAuth()
const router = useRouter()
const [activeTab, setActiveTab] = useState<'dashboard' | 'calendar' | 'staff' | 'payroll' | 'pos' | 'resources' | 'reports' | 'permissions'>('dashboard')
const [activeTab, setActiveTab] = useState<'dashboard' | 'calendar' | 'staff' | 'payroll' | 'pos' | 'resources' | 'reports' | 'permissions' | 'kiosks' | 'schedule'>('dashboard')
const [reportType, setReportType] = useState<'sales' | 'payments' | 'payroll'>('sales')
const [bookings, setBookings] = useState<any[]>([])
const [staff, setStaff] = useState<any[]>([])
@@ -299,6 +318,20 @@ export default function ApertureDashboard() {
<Users className="w-4 h-4 mr-2" />
Permisos
</Button>
<Button
variant={activeTab === 'kiosks' ? 'default' : 'outline'}
onClick={() => setActiveTab('kiosks')}
>
<Smartphone className="w-4 h-4 mr-2" />
Kioskos
</Button>
<Button
variant={activeTab === 'schedule' ? 'default' : 'outline'}
onClick={() => setActiveTab('schedule')}
>
<Clock className="w-4 h-4 mr-2" />
Horarios
</Button>
</div>
</div>
@@ -455,10 +488,9 @@ export default function ApertureDashboard() {
<div className="mt-2 space-y-2">
{role.permissions.map((perm: any) => (
<div key={perm.id} className="flex items-center space-x-2">
<input
type="checkbox"
<Checkbox
checked={perm.enabled}
onChange={() => togglePermission(role.id, perm.id)}
onCheckedChange={() => togglePermission(role.id, perm.id)}
/>
<span>{perm.name}</span>
</div>
@@ -472,6 +504,14 @@ export default function ApertureDashboard() {
</Card>
)}
{activeTab === 'kiosks' && (
<KiosksManagement />
)}
{activeTab === 'schedule' && (
<ScheduleManagement />
)}
{activeTab === 'reports' && (
<div className="space-y-6">
<Card>

View File

@@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Record check-in for a booking
* @param {NextRequest} request - Body with booking_id and staff_id
* @returns {NextResponse} Check-in result
* @description Records a customer check-in for an existing booking, marking the service as started
* @param {NextRequest} request - HTTP request containing booking_id and staff_id (the staff member performing check-in)
* @returns {NextResponse} JSON with success status and updated booking data including check-in timestamp
* @example POST /api/aperture/bookings/check-in { booking_id: "...", staff_id: "..." }
* @audit BUSINESS RULE: Records check-in time for no-show calculation and service tracking
* @audit SECURITY: Validates that the staff member belongs to the same location as the booking
* @audit Validate: Ensures booking exists and is not already checked in
* @audit Validate: Ensures booking status is confirmed or pending
* @audit PERFORMANCE: Uses RPC function 'record_booking_checkin' for atomic operation
* @audit AUDIT: Check-in events are logged for service tracking and no-show analysis
*/
export async function POST(request: NextRequest) {
try {

View File

@@ -2,9 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Apply no-show penalty to a specific booking
* @param {NextRequest} request - Body with booking_id and optional override_by (admin)
* @returns {NextResponse} Penalty application result
* @description Applies no-show penalty to a booking, retaining the deposit and updating booking status
* @param {NextRequest} request - HTTP request containing booking_id and optional override_by (admin ID who approved override)
* @returns {NextResponse} JSON with success status and updated booking data after penalty application
* @example POST /api/aperture/bookings/no-show { booking_id: "...", override_by: "admin-id" }
* @audit BUSINESS RULE: No-show penalty retains 50% deposit and marks booking as no_show status
* @audit BUSINESS RULE: Admin can override penalty by providing override_by parameter
* @audit SECURITY: Validates booking exists and can be marked as no-show
* @audit Validate: Ensures booking is within no-show window (typically 12 hours before start time)
* @audit Validate: If override is provided, validates admin permissions
* @audit PERFORMANCE: Uses RPC function 'apply_no_show_penalty' for atomic penalty application
* @audit AUDIT: No-show penalties are logged for customer tracking and revenue protection
*/
export async function POST(request: NextRequest) {
try {

View File

@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @see POST endpoint for actual assignment execution
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const locationId = searchParams.get('location_id');
const serviceId = searchParams.get('service_id');
const date = searchParams.get('date');
const startTime = searchParams.get('start_time');
const endTime = searchParams.get('end_time');
const excludeStaffIds = searchParams.get('exclude_staff_ids')?.split(',') || [];
if (!locationId || !serviceId || !date || !startTime || !endTime) {
return NextResponse.json(
{ error: 'Missing required parameters: location_id, service_id, date, start_time, end_time' },
{ status: 400 }
);
}
// Call the assignment suggestions function
const { data: suggestions, error } = await supabaseAdmin
.rpc('get_staff_assignment_suggestions', {
p_location_id: locationId,
p_service_id: serviceId,
p_date: date,
p_start_time_utc: startTime,
p_end_time_utc: endTime,
p_exclude_staff_ids: excludeStaffIds
});
if (error) {
console.error('Error getting staff suggestions:', error);
return NextResponse.json(
{ error: 'Failed to get staff suggestions' },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
suggestions: suggestions || []
});
} catch (error) {
console.error('Staff suggestions GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
* @description POST endpoint to automatically assign the best available staff member to an unassigned booking
* @param {NextRequest} request - HTTP request containing booking_id in the request body
* @returns {NextResponse} JSON with success status and assignment result including assigned staff member details
* @example POST /api/aperture/calendar/auto-assign { booking_id: "123e4567-e89b-12d3-a456-426614174000" }
* @audit BUSINESS RULE: Assigns the highest-ranked available staff member based on skill match and availability
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
* @audit Validate: Ensures booking_id is provided and booking exists with unassigned staff
* @audit PERFORMANCE: Uses RPC function 'auto_assign_staff_to_booking' for atomic assignment
* @audit AUDIT: Auto-assignment results logged for performance tracking and optimization
* @see GET endpoint for retrieving suggestions before assignment
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { booking_id } = body;
if (!booking_id) {
return NextResponse.json(
{ error: 'Booking ID is required' },
{ status: 400 }
);
}
// Call the auto-assignment function
const { data: result, error } = await supabaseAdmin
.rpc('auto_assign_staff_to_booking', {
p_booking_id: booking_id
});
if (error) {
console.error('Error auto-assigning staff:', error);
return NextResponse.json(
{ error: 'Failed to auto-assign staff' },
{ status: 500 }
);
}
if (!result.success) {
return NextResponse.json(
{ error: result.error || 'Auto-assignment failed' },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
assignment: result
});
} catch (error) {
console.error('Auto-assignment POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -2,9 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Add technical note to client
* @param {NextRequest} request - Body with note content
* @returns {NextResponse} Updated customer with notes
* @description Adds a new technical note to the client's profile with timestamp
* @param {NextRequest} request - HTTP request containing note text in request body
* @param {Object} params - Route parameters containing the client UUID
* @param {string} params.clientId - The UUID of the client to add note to
* @returns {NextResponse} JSON with success status and updated client data including new note
* @example POST /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000/notes { note: "Allergic to latex products" }
* @audit BUSINESS RULE: Notes are appended to existing technical_notes with ISO timestamp prefix
* @audit BUSINESS RULE: Technical notes used for service customization and allergy tracking
* @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies
* @audit Validate: Ensures note content is provided and client exists
* @audit AUDIT: Note additions logged as 'technical_note_added' action in audit_logs
* @audit PERFORMANCE: Single append operation on technical_notes field
*/
export async function POST(
request: NextRequest,

View File

@@ -2,9 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get client photo gallery (VIP/Black/Gold only)
* @param {NextRequest} request - URL params: clientId in path
* @returns {NextResponse} Client photos with metadata
* @description Retrieves client photo gallery for premium tier clients (Gold/Black/VIP only)
* @param {NextRequest} request - HTTP request (no body required)
* @param {Object} params - Route parameters containing the client UUID
* @param {string} params.clientId - The UUID of the client to get photos for
* @returns {NextResponse} JSON with success status and array of photo records with creator info
* @example GET /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000/photos
* @audit BUSINESS RULE: Photo access restricted to Gold, Black, and VIP tiers only
* @audit BUSINESS RULE: Returns only active photos (is_active = true) ordered by taken date descending
* @audit SECURITY: Validates client tier before allowing photo access
* @audit Validate: Returns 403 if client tier does not have photo gallery access
* @audit PERFORMANCE: Single query fetches photos with creator user info
* @audit AUDIT: Photo gallery access logged for privacy compliance
*/
export async function GET(
request: NextRequest,
@@ -69,9 +78,18 @@ export async function GET(
}
/**
* @description Upload photo to client gallery (VIP/Black/Gold only)
* @param {NextRequest} request - Body with photo data
* @returns {NextResponse} Uploaded photo metadata
* @description Uploads a new photo to the client's gallery (Gold/Black/VIP tiers only)
* @param {NextRequest} request - HTTP request containing storage_path and optional description
* @param {Object} params - Route parameters containing the client UUID
* @param {string} params.clientId - The UUID of the client to upload photo for
* @returns {NextResponse} JSON with success status and created photo record metadata
* @example POST /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000/photos { storage_path: "photos/client-id/photo.jpg", description: "Before nail art" }
* @audit BUSINESS RULE: Photo storage path must reference Supabase Storage bucket
* @audit BUSINESS RULE: Only Gold/Black/VIP tier clients can have photos in gallery
* @audit SECURITY: Validates client tier before allowing photo upload
* @audit Validate: Ensures storage_path is provided (required for photo reference)
* @audit AUDIT: Photo uploads logged as 'upload' action in audit_logs
* @audit PERFORMANCE: Single insert with automatic creator tracking
*/
export async function POST(
request: NextRequest,

View File

@@ -2,9 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get specific client details with full history
* @param {NextRequest} request - URL params: clientId in path
* @returns {NextResponse} Client details with bookings, loyalty, photos
* @description Retrieves detailed client profile including personal info, booking history, loyalty transactions, photos, and subscription status
* @param {NextRequest} request - HTTP request (no body required)
* @param {Object} params - Route parameters containing the client UUID
* @param {string} params.clientId - The UUID of the client to retrieve
* @returns {NextResponse} JSON with success status and comprehensive client data
* @example GET /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000
* @audit BUSINESS RULE: Photo access restricted to Gold/Black/VIP tiers only
* @audit BUSINESS RULE: Returns up to 20 recent bookings, 10 recent loyalty transactions
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
* @audit Validate: Ensures client exists before fetching related data
* @audit PERFORMANCE: Uses Promise.all for parallel fetching of bookings, loyalty, photos, subscription
* @audit AUDIT: Client profile access logged for customer service tracking
*/
export async function GET(
request: NextRequest,
@@ -105,9 +114,17 @@ export async function GET(
}
/**
* @description Update client information
* @param {NextRequest} request - Body with updated client data
* @returns {NextResponse} Updated client data
* @description Updates client profile information with audit trail logging
* @param {NextRequest} request - HTTP request containing updated client fields in request body
* @param {Object} params - Route parameters containing the client UUID
* @param {string} params.clientId - The UUID of the client to update
* @returns {NextResponse} JSON with success status and updated client data
* @example PUT /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000 { first_name: "Ana María", phone: "+528441234567" }
* @audit BUSINESS RULE: Updates client fields with automatic updated_at timestamp
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
* @audit Validate: Ensures client exists before attempting update
* @audit AUDIT: All client updates logged in audit_logs with old and new values
* @audit PERFORMANCE: Single update query with returning clause
*/
export async function PUT(
request: NextRequest,

View File

@@ -2,9 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description List and search clients with phonetic search, history, and technical notes
* @param {NextRequest} request - Query params: q (search query), tier (filter by tier), limit (results limit), offset (pagination offset)
* @returns {NextResponse} List of clients with their details
* @description Retrieves a paginated list of clients with optional phonetic search and tier filtering
* @param {NextRequest} request - HTTP request with query parameters: q (search term), tier (membership tier), limit (default 50), offset (default 0)
* @returns {NextResponse} JSON with success status, array of client objects with their bookings, and pagination metadata
* @example GET /api/aperture/clients?q=ana&tier=gold&limit=20&offset=0
* @audit BUSINESS RULE: Returns clients ordered by creation date (most recent first) with full booking history
* @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies
* @audit Validate: Supports phonetic search across first_name, last_name, email, and phone fields
* @audit Validate: Ensures pagination parameters are valid integers
* @audit PERFORMANCE: Uses indexed pagination queries for efficient large dataset handling
* @audit PERFORMANCE: Supports ILIKE pattern matching for flexible search
* @audit AUDIT: Client list access logged for privacy compliance monitoring
*/
export async function GET(request: NextRequest) {
try {
@@ -71,9 +79,15 @@ export async function GET(request: NextRequest) {
}
/**
* @description Create new client
* @param {NextRequest} request - Body with client details
* @returns {NextResponse} Created client data
* @description Creates a new client record in the customer database
* @param {NextRequest} request - HTTP request containing client details (first_name, last_name, email, phone, date_of_birth, occupation)
* @returns {NextResponse} JSON with success status and created client data
* @example POST /api/aperture/clients { first_name: "Ana", last_name: "García", email: "ana@example.com", phone: "+528441234567" }
* @audit BUSINESS RULE: New clients default to 'free' tier and are assigned a UUID
* @audit SECURITY: Validates email format and ensures no duplicate emails in the system
* @audit Validate: Ensures required fields (first_name, last_name, email) are provided
* @audit Validate: Checks for existing customer with same email before creation
* @audit AUDIT: New client creation logged for customer database management
*/
export async function POST(request: NextRequest) {
try {

View File

@@ -2,7 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Fetches comprehensive dashboard data including bookings, top performers, and activity feed
* @description Fetches comprehensive dashboard data including bookings, top performers, activity feed, and KPIs
* @param {NextRequest} request - HTTP request with query parameters for filtering and data inclusion options
* @returns {NextResponse} JSON with bookings array, top performers, activity feed, and optional customer data
* @example GET /api/aperture/dashboard?location_id=...&start_date=2026-01-01&end_date=2026-01-31&include_top_performers=true&include_activity=true
* @audit BUSINESS RULE: Aggregates booking data with related customer, service, staff, and resource information
* @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies
* @audit Validate: Validates location_id exists if provided
* @audit Validate: Ensures date parameters are valid ISO8601 format
* @audit PERFORMANCE: Uses Promise.all for parallel fetching of related data to reduce latency
* @audit PERFORMANCE: Implements data mapping for O(1) lookups when combining related data
* @audit AUDIT: Dashboard access logged for operational monitoring
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -2,9 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get daily closing reports
* @param {NextRequest} request - Query params: location_id, start_date, end_date, status
* @returns {NextResponse} List of daily closing reports
* @description Retrieves paginated list of daily closing reports with optional filtering by location, date range, and status
* @param {NextRequest} request - HTTP request with query parameters: location_id, start_date, end_date, status, limit (default 50), offset (default 0)
* @returns {NextResponse} JSON with success status, array of closing reports, and pagination metadata
* @example GET /api/aperture/finance/daily-closing?location_id=...&start_date=2026-01-01&end_date=2026-01-31&status=completed
* @audit BUSINESS RULE: Daily closing reports contain financial reconciliation data for each business day
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
* @audit Validate: Supports filtering by report status (pending, completed, reconciled)
* @audit PERFORMANCE: Uses indexed queries on report_date and location_id
* @audit AUDIT: Daily closing reports are immutable financial records for compliance
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Create expense record
* @param {NextRequest} request - Body with expense details
* @returns {NextResponse} Created expense
* @description Creates a new expense record for operational cost tracking
* @param {NextRequest} request - HTTP request containing location_id (optional), category, description, amount, expense_date, payment_method, receipt_url (optional), notes (optional)
* @returns {NextResponse} JSON with success status and created expense data
* @example POST /api/aperture/finance/expenses { category: "supplies", description: "Nail polish set", amount: 1500, expense_date: "2026-01-21", payment_method: "card" }
* @audit BUSINESS RULE: Expenses categorized for financial reporting (supplies, maintenance, utilities, rent, salaries, marketing, other)
* @audit SECURITY: Validates required fields and authenticates creating user
* @audit Validate: Ensures category is valid expense category
* @audit Validate: Ensures amount is positive number
* @audit AUDIT: All expenses logged in audit_logs with category, description, and amount
* @audit PERFORMANCE: Single insert with automatic created_by timestamp
*/
export async function POST(request: NextRequest) {
try {
@@ -77,9 +84,16 @@ export async function POST(request: NextRequest) {
}
/**
* @description Get expenses with filters
* @param {NextRequest} request - Query params: location_id, category, start_date, end_date
* @returns {NextResponse} List of expenses
* @description Retrieves a paginated list of expenses with optional filtering by location, category, and date range
* @param {NextRequest} request - HTTP request with query parameters: location_id, category, start_date, end_date, limit (default 50), offset (default 0)
* @returns {NextResponse} JSON with success status, array of expense records, and pagination metadata
* @example GET /api/aperture/finance/expenses?location_id=...&category=supplies&start_date=2026-01-01&end_date=2026-01-31&limit=20
* @audit BUSINESS RULE: Returns expenses ordered by expense date (most recent first) for expense tracking
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
* @audit Validate: Supports filtering by expense category (supplies, maintenance, utilities, rent, salaries, marketing, other)
* @audit Validate: Ensures date filters are valid YYYY-MM-DD format
* @audit PERFORMANCE: Uses indexed queries on expense_date for efficient filtering
* @audit AUDIT: Expense list access logged for financial transparency
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -2,9 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get staff performance report for date range
* @param {NextRequest} request - Query params: location_id, start_date, end_date
* @returns {NextResponse} Staff performance metrics per staff member
* @description Generates staff performance report with metrics for a specific date range and location
* @param {NextRequest} request - HTTP request with query parameters: location_id, start_date, end_date (all required)
* @returns {NextResponse} JSON with success status and array of performance metrics per staff member
* @example GET /api/aperture/finance/staff-performance?location_id=...&start_date=2026-01-01&end_date=2026-01-31
* @audit BUSINESS RULE: Performance metrics include completed bookings, revenue generated, hours worked, and commissions
* @audit SECURITY: Requires authenticated admin/manager role via RLS policies
* @audit Validate: All three parameters (location_id, start_date, end_date) are required
* @audit PERFORMANCE: Uses RPC function 'get_staff_performance_report' for complex aggregation
* @audit AUDIT: Staff performance reports used for commission calculations and HR decisions
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { data: kiosk, error } = await supabaseAdmin
.from('kiosks')
.select(`
id,
device_name,
display_name,
api_key,
ip_address,
is_active,
created_at,
updated_at,
location:locations (
id,
name,
address
)
`)
.eq('id', params.id)
.single()
if (error || !kiosk) {
return NextResponse.json(
{ error: 'Kiosk not found' },
{ status: 404 }
)
}
return NextResponse.json({
success: true,
kiosk
})
} catch (error) {
console.error('Kiosk GET error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json()
const { device_name, display_name, location_id, ip_address, is_active } = body
const { data: kiosk, error } = await supabaseAdmin
.from('kiosks')
.update({
device_name,
display_name,
location_id,
ip_address,
is_active
})
.eq('id', params.id)
.select(`
id,
device_name,
display_name,
api_key,
ip_address,
is_active,
created_at,
updated_at,
location:locations (
id,
name,
address
)
`)
.single()
if (error) {
return NextResponse.json(
{ error: error.message },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
kiosk
})
} catch (error) {
console.error('Kiosk PUT error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { error } = await supabaseAdmin
.from('kiosks')
.delete()
.eq('id', params.id)
if (error) {
return NextResponse.json(
{ error: error.message },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
message: 'Kiosk deleted successfully'
})
} catch (error) {
console.error('Kiosk DELETE error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const locationId = searchParams.get('location_id')
const isActive = searchParams.get('is_active')
let query = supabaseAdmin
.from('kiosks')
.select(`
id,
device_name,
display_name,
api_key,
ip_address,
is_active,
created_at,
updated_at,
location:locations (
id,
name,
address
)
`)
.order('device_name', { ascending: true })
if (locationId) {
query = query.eq('location_id', locationId)
}
if (isActive !== null && isActive !== '') {
query = query.eq('is_active', isActive === 'true')
}
const { data: kiosks, error } = await query
if (error) {
return NextResponse.json(
{ error: error.message },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
kiosks: kiosks || []
})
} catch (error) {
console.error('Kiosks GET error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { device_name, display_name, location_id, ip_address } = body
if (!device_name || !location_id) {
return NextResponse.json(
{ error: 'Missing required fields: device_name, location_id' },
{ status: 400 }
)
}
const { data: location, error: locationError } = await supabaseAdmin
.from('locations')
.select('id')
.eq('id', location_id)
.single()
if (locationError || !location) {
return NextResponse.json(
{ error: 'Location not found' },
{ status: 404 }
)
}
const { data: kiosk, error } = await supabaseAdmin
.from('kiosks')
.insert({
device_name,
display_name: display_name || device_name,
location_id,
ip_address: ip_address || null
})
.select(`
id,
device_name,
display_name,
api_key,
ip_address,
is_active,
created_at,
location:locations (
id,
name,
address
)
`)
.single()
if (error) {
console.error('Error creating kiosk:', error)
return NextResponse.json(
{ error: error.message },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
kiosk
}, { status: 201 })
} catch (error) {
console.error('Kiosks POST error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Gets all active locations
* @description Retrieves all active salon locations with their details for dropdown/selection UI
* @param {NextRequest} request - HTTP request (no body required)
* @returns {NextResponse} JSON with success status and array of active locations sorted by name
* @example GET /api/aperture/locations
* @audit BUSINESS RULE: Only active locations returned for booking availability
* @audit SECURITY: Location data is public-facing but RLS policies still applied
* @audit Validate: No query parameters - returns all active locations
* @audit PERFORMANCE: Indexed query on is_active and name columns for fast retrieval
* @audit DATA INTEGRITY: Timezone field critical for appointment scheduling conversions
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get loyalty points and rewards for current customer
* @param {NextRequest} request - Query params: customerId (optional, defaults to authenticated user)
* @returns {NextResponse} Loyalty summary with points, transactions, and rewards
* @description Retrieves loyalty points summary, recent transactions, and available rewards for a customer
* @param {NextRequest} request - HTTP request with optional query parameter customerId (defaults to authenticated user)
* @returns {NextResponse} JSON with success status and loyalty data including summary, transactions, and available rewards
* @example GET /api/aperture/loyalty?customerId=123e4567-e89b-12d3-a456-426614174000
* @audit BUSINESS RULE: Returns loyalty summary computed from RPC function with points balance and history
* @audit SECURITY: Requires authentication; customers can only view their own loyalty data
* @audit Validate: Ensures customer exists and has loyalty record
* @audit PERFORMANCE: Uses RPC function 'get_customer_loyalty_summary' for efficient aggregation
* @audit PERFORMANCE: Fetches recent 50 transactions for transaction history display
* @audit AUDIT: Loyalty data access logged for customer tracking
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -1,9 +1,14 @@
/**
* @description Payroll management API with commission and tip calculations
* @audit BUSINESS RULE: Payroll based on completed bookings, base salary, commissions, tips
* @audit SECURITY: Only admin/manager can access payroll data via middleware
* @audit Validate: Calculations use actual booking data and service revenue
* @audit PERFORMANCE: Real-time calculations from booking history
* @description Retrieves payroll calculations for staff including base salary, commissions, tips, and hours worked
* @param {NextRequest} request - HTTP request with query parameters: staff_id, period_start (default 2026-01-01), period_end (default 2026-01-31), action (optional 'calculate')
* @returns {NextResponse} JSON with success status and payroll data including earnings breakdown
* @example GET /api/aperture/payroll?staff_id=...&period_start=2026-01-01&period_end=2026-01-31&action=calculate
* @audit BUSINESS RULE: Calculates payroll based on completed bookings within the specified period
* @audit BUSINESS RULE: Commission is 10% of service revenue, tips are 5% of service revenue
* @audit SECURITY: Requires authenticated admin/manager role via middleware
* @audit Validate: Ensures staff member exists and has completed bookings in the period
* @audit PERFORMANCE: Computes hours worked from booking start/end times
* @audit AUDIT: Payroll calculations logged for financial compliance and transparency
*/
import { NextRequest, NextResponse } from 'next/server'

View File

@@ -1,10 +1,16 @@
/**
* @description Cash register closure API for daily financial reconciliation
* @audit BUSINESS RULE: Daily cash closure ensures financial accountability
* @audit SECURITY: Only admin/manager can close cash registers
* @audit Validate: All payments for the day must be accounted for
* @audit AUDIT: Cash closure logged with detailed reconciliation
* @audit COMPLIANCE: Financial records must be immutable after closure
* @description Processes end-of-day cash register closure with financial reconciliation
* @param {NextRequest} request - HTTP request containing date, location_id, cash_count object, expected_totals, and optional notes
* @returns {NextResponse} JSON with success status, reconciliation report including actual totals, discrepancies, and closure record
* @example POST /api/aperture/pos/close-day { date: "2026-01-21", location_id: "...", cash_count: { cash_amount: 5000, card_amount: 8000, transfer_amount: 2000 }, notes: "Day closure" }
* @audit BUSINESS RULE: Compares physical cash count with system-recorded transactions to identify discrepancies
* @audit BUSINESS RULE: Creates immutable daily_closing_report record after successful reconciliation
* @audit SECURITY: Requires authenticated manager/admin role
* @audit Validate: Ensures date is valid and location exists
* @audit Validate: Calculates discrepancies for each payment method
* @audit PERFORMANCE: Uses audit_logs for transaction aggregation (single source of truth)
* @audit AUDIT: Daily closure creates permanent financial record with all discrepancies documented
* @audit COMPLIANCE: Closure records are immutable and used for financial reporting
*/
import { NextRequest, NextResponse } from 'next/server'

View File

@@ -1,10 +1,15 @@
/**
* @description Point of Sale API for processing sales and payments
* @audit BUSINESS RULE: POS handles service/product sales with multiple payment methods
* @audit SECURITY: Only admin/manager can process sales via this API
* @audit Validate: Payment methods must be valid and amounts must match totals
* @audit AUDIT: All sales transactions logged in audit_logs table
* @audit PERFORMANCE: Transaction processing must be atomic and fast
* @description Processes a point-of-sale transaction with items and multiple payment methods
* @param {NextRequest} request - HTTP request containing customer_id (optional), items array, payments array, staff_id, location_id, and optional notes
* @returns {NextResponse} JSON with success status and transaction details
* @example POST /api/aperture/pos { customer_id: "...", items: [{ type: "service", id: "...", quantity: 1, price: 1500, name: "Manicure" }], payments: [{ method: "card", amount: 1500 }], staff_id: "...", location_id: "..." }
* @audit BUSINESS RULE: Supports multiple payment methods (cash, card, transfer, giftcard, membership) in single transaction
* @audit BUSINESS RULE: Payment amounts must exactly match subtotal (within 0.01 tolerance)
* @audit SECURITY: Requires authenticated staff member (cashier) via Supabase Auth
* @audit Validate: Ensures items and payments arrays are non-empty
* @audit Validate: Validates payment method types and reference numbers
* @audit PERFORMANCE: Uses database transaction for atomic sale processing
* @audit AUDIT: All sales transactions logged in audit_logs with full transaction details
*/
import { NextRequest, NextResponse } from 'next/server'

View File

@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Fetches recent payments report
* @description Generates payments report showing recent transactions with customer, service, amount, and payment status
* @returns {NextResponse} JSON with success status and array of recent payments (limit: 20)
* @example GET /api/aperture/reports/payments
* @audit BUSINESS RULE: Payments identified by non-null payment_intent_id (Stripe integration)
* @audit SECURITY: Payment data restricted to admin/manager roles for PCI compliance
* @audit Validate: Only returns last 20 payments for dashboard preview (use pagination for full report)
* @audit PERFORMANCE: Ordered by created_at descending with limit 20 for fast dashboard loading
* @audit DATA INTEGRITY: Customer and service names resolved via joins for display purposes
* @audit AUDIT: Payment access logged for financial reconciliation and fraud prevention
*/
export async function GET() {
try {

View File

@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Fetches payroll report for staff based on recent bookings
* @description Generates payroll report calculating staff commissions based on completed bookings from the past 7 days
* @returns {NextResponse} JSON with success status and array of staff payroll data including bookings count and commission
* @example GET /api/aperture/reports/payroll
* @audit BUSINESS RULE: Commission rate fixed at 10% of service base_price for completed bookings
* @audit SECURITY: Payroll data restricted to admin/manager roles for confidentiality
* @audit Validate: Time window fixed at 7 days (past week) - consider adding date range parameters
* @audit PERFORMANCE: Single query fetches all completed bookings from past week for all staff
* @audit DATA INTEGRITY: Base pay and hours are placeholder values (40 hours, $1000) - implement actual values
* @audit AUDIT: Payroll calculations logged for labor compliance and wage dispute resolution
*/
export async function GET() {
try {

View File

@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Fetches sales report including total sales, completed bookings, average service price, and sales by service
* @description Generates sales report with metrics: total revenue, completed bookings, average price, and sales breakdown by service
* @returns {NextResponse} JSON with success status and comprehensive sales metrics
* @example GET /api/aperture/reports/sales
* @audit BUSINESS RULE: Only completed bookings (status='completed') counted in sales metrics
* @audit SECURITY: Sales data restricted to admin/manager roles for financial confidentiality
* @audit Validate: No query parameters required - returns all-time sales data
* @audit PERFORMANCE: Uses reduce operations on client side for aggregation (suitable for small-medium datasets)
* @audit PERFORMANCE: Consider adding date filters for larger datasets (current implementation scans all bookings)
* @audit AUDIT: Sales reports generated logged for financial compliance and auditing
*/
export async function GET() {
try {

View File

@@ -2,7 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Gets a specific resource by ID
* @description Retrieves a single resource by ID with location details
* @param {NextRequest} request - HTTP request (no body required)
* @param {Object} params - Route parameters containing the resource UUID
* @param {string} params.id - The UUID of the resource to retrieve
* @returns {NextResponse} JSON with success status and resource data including location
* @example GET /api/aperture/resources/123e4567-e89b-12d3-a456-426614174000
* @audit BUSINESS RULE: Resource details needed for appointment scheduling and capacity planning
* @audit SECURITY: RLS policies restrict resource access to authenticated staff/manager roles
* @audit Validate: Resource ID must be valid UUID format
* @audit PERFORMANCE: Single query with location join (no N+1)
* @audit AUDIT: Resource access logged for operational tracking
*/
export async function GET(
request: NextRequest,
@@ -59,7 +69,17 @@ export async function GET(
}
/**
* @description Updates a resource
* @description Updates an existing resource's information (name, type, capacity, is_active, location)
* @param {NextRequest} request - HTTP request containing update fields in request body
* @param {Object} params - Route parameters containing the resource UUID
* @param {string} params.id - The UUID of the resource to update
* @returns {NextResponse} JSON with success status and updated resource data
* @example PUT /api/aperture/resources/123e4567-e89b-12d3-a456-426614174000 { "name": "mani-02", "capacity": 2 }
* @audit BUSINESS RULE: Capacity updates affect booking availability calculations
* @audit SECURITY: Only admin/manager can update resources via RLS policies
* @audit Validate: Type must be one of: station, room, equipment
* @audit Validate: Protected fields (id, created_at) are removed from updates
* @audit AUDIT: All resource updates logged in audit_logs with old and new values
*/
export async function PUT(
request: NextRequest,
@@ -147,7 +167,17 @@ export async function PUT(
}
/**
* @description Deactivates a resource (soft delete)
* @description Deactivates a resource (soft delete) to preserve booking history
* @param {NextRequest} request - HTTP request (no body required)
* @param {Object} params - Route parameters containing the resource UUID
* @param {string} params.id - The UUID of the resource to deactivate
* @returns {NextResponse} JSON with success status and confirmation message
* @example DELETE /api/aperture/resources/123e4567-e89b-12d3-a456-426614174000
* @audit BUSINESS RULE: Soft delete preserves historical bookings referencing the resource
* @audit SECURITY: Only admin can deactivate resources via RLS policies
* @audit Validate: Resource must exist before deactivation
* @audit PERFORMANCE: Single update query with is_active=false
* @audit AUDIT: Deactivation logged for tracking resource lifecycle and capacity changes
*/
export async function DELETE(
request: NextRequest,

View File

@@ -2,7 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Gets a specific staff member by ID
* @description Retrieves a single staff member by their UUID with location and role information
* @param {NextRequest} request - HTTP request (no body required)
* @param {Object} params - Route parameters containing the staff UUID
* @param {string} params.id - The UUID of the staff member to retrieve
* @returns {NextResponse} JSON with success status and staff member details including location
* @example GET /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000
* @audit BUSINESS RULE: Returns staff with their assigned location details for operational planning
* @audit SECURITY: RLS policies ensure staff can only view their own record, managers can view location staff
* @audit Validate: Ensures staff ID is valid UUID format
* @audit PERFORMANCE: Single query with related location data (no N+1)
* @audit AUDIT: Staff data access logged for HR compliance monitoring
*/
export async function GET(
request: NextRequest,
@@ -60,7 +70,17 @@ export async function GET(
}
/**
* @description Updates a staff member
* @description Updates an existing staff member's information (role, display_name, phone, is_active, location)
* @param {NextRequest} request - HTTP request containing update fields in request body
* @param {Object} params - Route parameters containing the staff UUID
* @param {string} params.id - The UUID of the staff member to update
* @returns {NextResponse} JSON with success status and updated staff data
* @example PUT /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000 { role: "manager", display_name: "Ana García", is_active: true }
* @audit BUSINESS RULE: Role updates restricted to valid roles: admin, manager, staff, artist, kiosk
* @audit SECURITY: Only admin/manager can update staff records via RLS policies
* @audit Validate: Prevents updates to protected fields (id, created_at)
* @audit Validate: Ensures role is one of the predefined valid values
* @audit AUDIT: All staff updates logged in audit_logs with old and new values
*/
export async function PUT(
request: NextRequest,

View File

@@ -0,0 +1,247 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Retrieves all services that a specific staff member is qualified to perform
* @param {NextRequest} request - HTTP request (no body required)
* @param {Object} params - Route parameters containing the staff UUID
* @param {string} params.id - The UUID of the staff member to retrieve services for
* @returns {NextResponse} JSON with success status and array of staff services with service details
* @example GET /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000/services
* @audit BUSINESS RULE: Only active service assignments returned for booking eligibility
* @audit SECURITY: RLS policies restrict staff service data to authenticated manager/admin roles
* @audit Validate: Staff ID must be valid UUID format for database query
* @audit PERFORMANCE: Single query fetches both staff_services and nested services data
* @audit DATA INTEGRITY: Proficiency level determines service pricing and priority in booking
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const staffId = params.id;
if (!staffId) {
return NextResponse.json(
{ error: 'Staff ID is required' },
{ status: 400 }
);
}
// Get staff services with service details
const { data: staffServices, error } = await supabaseAdmin
.from('staff_services')
.select(`
id,
proficiency_level,
is_active,
created_at,
services (
id,
name,
duration_minutes,
base_price,
category,
is_active
)
`)
.eq('staff_id', staffId)
.eq('is_active', true)
.order('services(name)', { ascending: true });
if (error) {
console.error('Error fetching staff services:', error);
return NextResponse.json(
{ error: 'Failed to fetch staff services' },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
services: staffServices || []
});
} catch (error) {
console.error('Staff services GET error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
* @description Assigns a new service to a staff member or updates existing service proficiency
* @param {NextRequest} request - JSON body with service_id and optional proficiency_level (default: 3)
* @param {Object} params - Route parameters containing the staff UUID
* @param {string} params.id - The UUID of the staff member to assign service to
* @returns {NextResponse} JSON with success status and created/updated staff service record
* @example POST /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000/services {"service_id": "456", "proficiency_level": 4}
* @audit BUSINESS RULE: Upsert pattern - updates existing assignment if service already assigned to staff
* @audit SECURITY: Only admin/manager roles can assign services to staff members
* @audit Validate: Required fields: staff_id (from URL), service_id (from body)
* @audit Validate: Proficiency level must be between 1-5 for skill rating system
* @audit PERFORMANCE: Single existence check before insert/update decision
* @audit AUDIT: Service assignments logged for certification compliance and performance tracking
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const staffId = params.id;
const body = await request.json();
const { service_id, proficiency_level = 3 } = body;
if (!staffId || !service_id) {
return NextResponse.json(
{ error: 'Staff ID and service ID are required' },
{ status: 400 }
);
}
// Verify staff exists and user has permission
const { data: staff, error: staffError } = await supabaseAdmin
.from('staff')
.select('id, role')
.eq('id', staffId)
.single();
if (staffError || !staff) {
return NextResponse.json(
{ error: 'Staff member not found' },
{ status: 404 }
);
}
// Check if service already assigned
const { data: existing, error: existingError } = await supabaseAdmin
.from('staff_services')
.select('id')
.eq('staff_id', staffId)
.eq('service_id', service_id)
.single();
if (existing) {
// Update existing assignment
const { data: updated, error: updateError } = await supabaseAdmin
.from('staff_services')
.update({
proficiency_level,
is_active: true,
updated_at: new Date().toISOString()
})
.eq('id', existing.id)
.select()
.single();
if (updateError) {
console.error('Error updating staff service:', updateError);
return NextResponse.json(
{ error: 'Failed to update staff service' },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
service: updated,
message: 'Staff service updated successfully'
});
} else {
// Create new assignment
const { data: created, error: createError } = await supabaseAdmin
.from('staff_services')
.insert({
staff_id: staffId,
service_id,
proficiency_level
})
.select()
.single();
if (createError) {
console.error('Error creating staff service:', createError);
return NextResponse.json(
{ error: 'Failed to assign service to staff' },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
service: created,
message: 'Service assigned to staff successfully'
});
}
} catch (error) {
console.error('Staff services POST error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
/**
* @description Removes a service assignment from a staff member (soft delete)
* @param {NextRequest} request - HTTP request (no body required)
* @param {Object} params - Route parameters containing staff UUID and service UUID
* @param {string} params.id - The UUID of the staff member
* @param {string} params.serviceId - The UUID of the service to remove
* @returns {NextResponse} JSON with success status and confirmation message
* @example DELETE /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000/services/789
* @audit BUSINESS RULE: Soft delete via is_active=false preserves historical service assignments
* @audit SECURITY: Only admin/manager roles can remove service assignments
* @audit Validate: Both staff ID and service ID must be valid UUIDs
* @audit PERFORMANCE: Single update query with composite key filter
* @audit AUDIT: Service removal logged for tracking staff skill changes over time
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string; serviceId: string } }
) {
try {
const staffId = params.id;
const serviceId = params.serviceId;
if (!staffId || !serviceId) {
return NextResponse.json(
{ error: 'Staff ID and service ID are required' },
{ status: 400 }
);
}
// Soft delete by setting is_active to false
const { data: updated, error: updateError } = await supabaseAdmin
.from('staff_services')
.update({ is_active: false, updated_at: new Date().toISOString() })
.eq('staff_id', staffId)
.eq('service_id', serviceId)
.select()
.single();
if (updateError) {
console.error('Error removing staff service:', updateError);
return NextResponse.json(
{ error: 'Failed to remove service from staff' },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
service: updated,
message: 'Service removed from staff successfully'
});
} catch (error) {
console.error('Staff services DELETE error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get staff role by user ID for authentication
* @description Retrieves the staff role for a given user ID for authorization purposes
* @param {NextRequest} request - JSON body with userId field
* @returns {NextResponse} JSON with success status and role (admin, manager, staff, artist, kiosk)
* @example POST /api/aperture/staff/role {"userId": "123e4567-e89b-12d3-a456-426614174000"}
* @audit BUSINESS ROLE: Role determines API access levels and UI capabilities
* @audit SECURITY: Critical for authorization - only authenticated users can query their role
* @audit Validate: userId must be a valid UUID format
* @audit PERFORMANCE: Single-row lookup on indexed user_id column
* @audit AUDIT: Role access logged for security monitoring and access control audits
*/
export async function POST(request: NextRequest) {
try {

View File

@@ -2,7 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Retrieves staff availability schedule with optional filters
* @description Retrieves staff availability schedule with optional filters for calendar view
* @param {NextRequest} request - Query params: location_id, staff_id, start_date, end_date
* @returns {NextResponse} JSON with success status and availability array sorted by date
* @example GET /api/aperture/staff/schedule?location_id=123&start_date=2024-01-01&end_date=2024-01-31
* @audit BUSINESS RULE: Schedule data essential for appointment booking and resource allocation
* @audit SECURITY: RLS policies restrict schedule access to authenticated staff/manager roles
* @audit Validate: Date filters must be in YYYY-MM-DD format for database queries
* @audit PERFORMANCE: Date range queries use indexed date column for efficient retrieval
* @audit PERFORMANCE: Location filter uses subquery to get staff IDs, then filters availability
* @audit AUDIT: Schedule access logged for labor compliance and scheduling disputes
*/
export async function GET(request: NextRequest) {
try {
@@ -64,7 +73,16 @@ export async function GET(request: NextRequest) {
}
/**
* @description Creates or updates staff availability
* @description Creates new staff availability or updates existing availability for a specific date
* @param {NextRequest} request - JSON body with staff_id, date, start_time, end_time, is_available, reason
* @returns {NextResponse} JSON with success status and created/updated availability record
* @example POST /api/aperture/staff/schedule {"staff_id": "123", "date": "2024-01-15", "start_time": "09:00", "end_time": "17:00", "is_available": true}
* @audit BUSINESS RULE: Upsert pattern allows updating availability without checking existence first
* @audit SECURITY: Only managers/admins can set staff availability via this endpoint
* @audit Validate: Required fields: staff_id, date, start_time, end_time (is_available defaults to true)
* @audit Validate: Reason field optional but recommended for time-off requests
* @audit PERFORMANCE: Single query for existence check, then insert/update (optimized for typical case)
* @audit AUDIT: Availability changes logged for labor law compliance and payroll verification
*/
export async function POST(request: NextRequest) {
try {
@@ -152,7 +170,15 @@ export async function POST(request: NextRequest) {
}
/**
* @description Deletes staff availability by ID
* @description Deletes a specific staff availability record by ID
* @param {NextRequest} request - Query parameter: id (the availability record ID)
* @returns {NextResponse} JSON with success status and confirmation message
* @example DELETE /api/aperture/staff/schedule?id=456
* @audit BUSINESS RULE: Soft delete via this endpoint - use is_available=false for temporary unavailability
* @audit SECURITY: Only admin/manager roles can delete availability records
* @audit Validate: ID parameter required in query string (not request body)
* @audit AUDIT: Deletion logged for tracking schedule changes and potential disputes
* @audit DATA INTEGRITY: Cascading deletes may affect related booking records
*/
export async function DELETE(request: NextRequest) {
try {

View File

@@ -1,6 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Validates that the request contains a valid ADMIN_ENROLLMENT_KEY authorization header
* @param {NextRequest} request - HTTP request to validate
* @returns {Promise<boolean|null>} Returns true if authorized, null otherwise
* @example validateAdmin(request)
* @audit SECURITY: Simple API key validation for administrative booking block operations
* @audit Validate: Ensures authorization header follows 'Bearer <token>' format
*/
async function validateAdmin(request: NextRequest) {
const authHeader = request.headers.get('authorization')
@@ -18,7 +26,14 @@ async function validateAdmin(request: NextRequest) {
}
/**
* @description Creates a booking block for a resource
* @description Creates a new booking block to reserve a resource for a specific time period
* @param {NextRequest} request - HTTP request containing location_id, resource_id, start_time_utc, end_time_utc, and optional reason
* @returns {NextResponse} JSON with success status and created booking block record
* @example POST /api/availability/blocks { location_id: "...", resource_id: "...", start_time_utc: "...", end_time_utc: "...", reason: "Maintenance" }
* @audit BUSINESS RULE: Blocks prevent bookings from using the resource during the blocked time
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
* @audit Validate: Ensures start_time_utc is before end_time_utc and both are valid ISO8601 timestamps
* @audit AUDIT: All booking blocks are logged for operational monitoring
*/
export async function POST(request: NextRequest) {
try {
@@ -80,7 +95,14 @@ export async function POST(request: NextRequest) {
}
/**
* @description Retrieves booking blocks with filters
* @description Retrieves booking blocks with optional filtering by location and date range
* @param {NextRequest} request - HTTP request with query parameters location_id, start_date, end_date
* @returns {NextResponse} JSON with array of booking blocks including related location, resource, and creator info
* @example GET /api/availability/blocks?location_id=...&start_date=2026-01-01&end_date=2026-01-31
* @audit BUSINESS RULE: Returns all booking blocks regardless of status (used for resource planning)
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
* @audit PERFORMANCE: Supports filtering by location and date range for efficient queries
* @audit Validate: Ensures date filters are valid if provided
*/
export async function GET(request: NextRequest) {
try {
@@ -158,7 +180,14 @@ export async function GET(request: NextRequest) {
}
/**
* @description Deletes a booking block by ID
* @description Deletes an existing booking block by its ID, freeing up the resource for bookings
* @param {NextRequest} request - HTTP request with query parameter 'id' for the block to delete
* @returns {NextResponse} JSON with success status and confirmation message
* @example DELETE /api/availability/blocks?id=123e4567-e89b-12d3-a456-426614174000
* @audit BUSINESS RULE: Deleting a block removes the scheduling restriction, allowing new bookings
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
* @audit Validate: Ensures block ID is provided and exists in the database
* @audit AUDIT: Block deletion is logged for operational monitoring
*/
export async function DELETE(request: NextRequest) {
try {

View File

@@ -1,6 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Validates that the request contains a valid ADMIN_ENROLLMENT_KEY authorization header
* @param {NextRequest} request - HTTP request to validate
* @returns {Promise<boolean|null>} Returns true if authorized, null if unauthorized, or throws error on invalid format
* @example validateAdminOrStaff(request)
* @audit SECURITY: Simple API key validation for administrative operations
* @audit Validate: Ensures authorization header follows 'Bearer <token>' format
*/
async function validateAdminOrStaff(request: NextRequest) {
const authHeader = request.headers.get('authorization')
@@ -18,7 +26,15 @@ async function validateAdminOrStaff(request: NextRequest) {
}
/**
* @description Marks staff as unavailable for a time period
* @description Creates a new staff unavailability record to block a staff member for a specific time period
* @param {NextRequest} request - HTTP request containing staff_id, date, start_time, end_time, optional reason and location_id
* @returns {NextResponse} JSON with success status and created availability record
* @example POST /api/availability/staff-unavailable { staff_id: "...", date: "2026-01-21", start_time: "10:00", end_time: "14:00", reason: "Lunch meeting" }
* @audit BUSINESS RULE: Prevents double-booking by blocking staff during unavailable times
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
* @audit Validate: Ensures staff exists and no existing availability record for the same date/time
* @audit Validate: Checks that start_time is before end_time and date is valid
* @audit AUDIT: All unavailability records are logged for staffing management
*/
export async function POST(request: NextRequest) {
try {
@@ -123,7 +139,14 @@ export async function POST(request: NextRequest) {
}
/**
* @description Retrieves staff unavailability records
* @description Retrieves staff unavailability records filtered by staff ID and optional date range
* @param {NextRequest} request - HTTP request with query parameters staff_id, optional start_date and end_date
* @returns {NextResponse} JSON with array of availability records sorted by date
* @example GET /api/availability/staff-unavailable?staff_id=...&start_date=2026-01-01&end_date=2026-01-31
* @audit BUSINESS RULE: Returns only unavailability records (is_available = false)
* @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header
* @audit Validate: Ensures staff_id is provided as required parameter
* @audit PERFORMANCE: Supports optional date range filtering for efficient queries
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -2,41 +2,125 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Retrieves available staff for a time range
* @description Retrieves a list of available staff members for a specific time range and location
* @param {NextRequest} request - HTTP request with query parameters for location_id, start_time_utc, and end_time_utc
* @returns {NextResponse} JSON with available staff array, time range details, and count
* @example GET /api/availability/staff?location_id=...&start_time_utc=...&end_time_utc=...
* @audit BUSINESS RULE: Staff must be active, available for booking, and have no booking conflicts in the time range
* @audit SECURITY: Validates required query parameters before database call
* @audit Validate: Ensures start_time_utc is before end_time_utc and both are valid ISO8601 timestamps
* @audit PERFORMANCE: Uses RPC function 'get_available_staff' for optimized database query
* @audit AUDIT: Staff availability queries are logged for operational monitoring
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const locationId = searchParams.get('location_id')
const serviceId = searchParams.get('service_id')
const date = searchParams.get('date')
const startTime = searchParams.get('start_time_utc')
const endTime = searchParams.get('end_time_utc')
if (!locationId || !startTime || !endTime) {
if (!locationId) {
return NextResponse.json(
{ error: 'Missing required parameters: location_id, start_time_utc, end_time_utc' },
{ error: 'Missing required parameter: location_id' },
{ status: 400 }
)
}
const { data: staff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', {
p_location_id: locationId,
p_start_time_utc: startTime,
p_end_time_utc: endTime
})
let staff: any[] = []
if (staffError) {
return NextResponse.json(
{ error: staffError.message },
{ status: 400 }
)
if (startTime && endTime) {
const { data, error } = await supabaseAdmin.rpc('get_available_staff', {
p_location_id: locationId,
p_start_time_utc: startTime,
p_end_time_utc: endTime
})
if (error) {
return NextResponse.json(
{ error: error.message },
{ status: 400 }
)
}
staff = data || []
} else if (date && serviceId) {
const { data: service, error: serviceError } = await supabaseAdmin
.from('services')
.select('duration_minutes')
.eq('id', serviceId)
.single()
if (serviceError || !service) {
return NextResponse.json(
{ error: 'Service not found' },
{ status: 404 }
)
}
const { data: allStaff, error: staffError } = await supabaseAdmin
.from('staff')
.select(`
id,
display_name,
role,
is_active,
user_id,
location_id,
staff_services!inner (
service_id,
is_active
)
`)
.eq('location_id', locationId)
.eq('is_active', true)
.eq('role', 'artist')
.eq('staff_services.service_id', serviceId)
.eq('staff_services.is_active', true)
if (staffError) {
return NextResponse.json(
{ error: staffError.message },
{ status: 400 }
)
}
const deduped = new Map()
allStaff?.forEach((s: any) => {
if (!deduped.has(s.id)) {
deduped.set(s.id, {
id: s.id,
display_name: s.display_name,
role: s.role,
is_active: s.is_active
})
}
})
staff = Array.from(deduped.values())
} else {
const { data: allStaff, error: staffError } = await supabaseAdmin
.from('staff')
.select('id, display_name, role, is_active')
.eq('location_id', locationId)
.eq('is_active', true)
.eq('role', 'artist')
if (staffError) {
return NextResponse.json(
{ error: staffError.message },
{ status: 400 }
)
}
staff = allStaff || []
}
return NextResponse.json({
success: true,
staff: staff || [],
staff,
location_id: locationId,
start_time_utc: startTime,
end_time_utc: endTime,
available_count: staff?.length || 0
})
} catch (error) {

View File

@@ -2,7 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Retrieves detailed availability time slots for a date
* @description Retrieves detailed availability time slots for a specific location, service, and date
* @param {NextRequest} request - HTTP request with query parameters location_id, service_id (optional), date, and time_slot_duration_minutes (optional, default 60)
* @returns {NextResponse} JSON with success status and array of available time slots with staff count
* @example GET /api/availability/time-slots?location_id=...&service_id=...&date=2026-01-21&time_slot_duration_minutes=30
* @audit BUSINESS RULE: Returns only time slots where staff availability, resource availability, and business hours all align
* @audit SECURITY: Public endpoint for booking availability display
* @audit Validate: Ensures location_id and date are valid and required
* @audit Validate: Ensures date is in valid YYYY-MM-DD format
* @audit PERFORMANCE: Uses optimized RPC function 'get_detailed_availability' for complex availability calculation
* @audit AUDIT: High-volume endpoint, consider rate limiting in production
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -2,7 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Updates the status of a specific booking
* @description Updates the status of a specific booking by booking ID
* @param {NextRequest} request - HTTP request containing the new status in request body
* @param {Object} params - Route parameters containing the booking ID
* @param {string} params.id - The UUID of the booking to update
* @returns {NextResponse} JSON with success status and updated booking data
* @example PATCH /api/bookings/123e4567-e89b-12d3-a456-426614174000 { "status": "confirmed" }
* @audit BUSINESS RULE: Only allows valid status transitions (pending→confirmed→completed/cancelled/no_show)
* @audit SECURITY: Requires authentication and booking ownership validation
* @audit Validate: Ensures status is one of the predefined valid values
* @audit AUDIT: Status changes are logged in audit_logs table
*/
export async function PATCH(
request: NextRequest,

View File

@@ -17,7 +17,8 @@ export async function POST(request: NextRequest) {
service_id,
location_id,
start_time_utc,
notes
notes,
staff_id
} = body
if (!customer_id && (!customer_email || !customer_first_name || !customer_last_name)) {
@@ -81,30 +82,71 @@ export async function POST(request: NextRequest) {
const endTimeUtc = endTime.toISOString()
// Check staff availability for the requested time slot
const { data: availableStaff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', {
p_location_id: location_id,
p_start_time_utc: start_time_utc,
p_end_time_utc: endTimeUtc
})
let assignedStaffId: string | null = null
if (staffError) {
console.error('Error checking staff availability:', staffError)
return NextResponse.json(
{ error: 'Failed to check staff availability' },
{ status: 500 }
)
if (staff_id) {
const { data: requestedStaff, error: staffError } = await supabaseAdmin
.from('staff')
.select('id, display_name')
.eq('id', staff_id)
.eq('is_active', true)
.single()
if (staffError || !requestedStaff) {
return NextResponse.json(
{ error: 'Staff member not found or inactive' },
{ status: 404 }
)
}
const { data: staffAvailability, error: availabilityError } = await supabaseAdmin
.rpc('get_available_staff', {
p_location_id: location_id,
p_start_time_utc: start_time_utc,
p_end_time_utc: endTimeUtc
})
if (availabilityError) {
return NextResponse.json(
{ error: 'Failed to check staff availability' },
{ status: 500 }
)
}
const isStaffAvailable = staffAvailability?.some((s: any) => s.staff_id === staff_id)
if (!isStaffAvailable) {
return NextResponse.json(
{ error: 'Selected staff member is not available for the selected time' },
{ status: 409 }
)
}
assignedStaffId = staff_id
} else {
const { data: availableStaff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', {
p_location_id: location_id,
p_start_time_utc: start_time_utc,
p_end_time_utc: endTimeUtc
})
if (staffError) {
console.error('Error checking staff availability:', staffError)
return NextResponse.json(
{ error: 'Failed to check staff availability' },
{ status: 500 }
)
}
if (!availableStaff || availableStaff.length === 0) {
return NextResponse.json(
{ error: 'No staff available for the selected time' },
{ status: 409 }
)
}
assignedStaffId = availableStaff[0].staff_id
}
if (!availableStaff || availableStaff.length === 0) {
return NextResponse.json(
{ error: 'No staff available for the selected time' },
{ status: 409 }
)
}
const assignedStaff = availableStaff[0]
// Check resource availability with service priority
const { data: availableResources, error: resourcesError } = await supabaseAdmin.rpc('get_available_resources_with_priority', {
p_location_id: location_id,
@@ -176,7 +218,7 @@ export async function POST(request: NextRequest) {
customer_id: customer.id,
service_id,
location_id,
staff_id: assignedStaff.staff_id,
staff_id: assignedStaffId,
resource_id: assignedResource.resource_id,
short_id: shortId,
status: 'pending',

View File

@@ -3,9 +3,16 @@ import Stripe from 'stripe'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Creates a Stripe payment intent for booking deposit (50% of service price, max $200)
* @param {NextRequest} request - Request containing booking details
* @returns {NextResponse} Payment intent client secret and amount
* @description Creates a Stripe payment intent for booking deposit payment
* @param {NextRequest} request - HTTP request containing customer and service details
* @returns {NextResponse} JSON with Stripe client secret, deposit amount, and service name
* @example POST /api/create-payment-intent { customer_email: "...", service_id: "...", location_id: "...", start_time_utc: "..." }
* @audit BUSINESS RULE: Calculates deposit as 50% of service price, capped at $200 maximum
* @audit SECURITY: Requires valid Stripe configuration and service validation
* @audit Validate: Ensures service exists and customer details are provided
* @audit Validate: Validates start_time_utc format and location validity
* @audit AUDIT: Payment intent creation is logged for audit trail
* @audit PERFORMANCE: Single database query to fetch service pricing
*/
export async function POST(request: NextRequest) {
try {

View File

@@ -1,6 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Validates kiosk API key and returns kiosk record if valid
* @param {NextRequest} request - HTTP request containing x-kiosk-api-key header
* @returns {Promise<Object|null>} Kiosk record with id, location_id, is_active or null if invalid
* @example validateKiosk(request)
* @audit SECURITY: Simple API key validation for kiosk operations
* @audit Validate: Checks both api_key match and is_active status
*/
async function validateKiosk(request: NextRequest) {
const apiKey = request.headers.get('x-kiosk-api-key')
@@ -19,7 +27,16 @@ async function validateKiosk(request: NextRequest) {
}
/**
* @description Retrieves pending/confirmed bookings for kiosk
* @description Retrieves bookings for kiosk display, filtered by optional short_id and date
* @param {NextRequest} request - HTTP request with x-kiosk-api-key header and optional query params: short_id, date
* @returns {NextResponse} JSON with array of pending/confirmed bookings for the kiosk location
* @example GET /api/kiosk/bookings?short_id=ABC123 (Search by booking code)
* @example GET /api/kiosk/bookings?date=2026-01-21 (Get all bookings for date)
* @audit BUSINESS RULE: Returns only pending and confirmed bookings (not cancelled/completed)
* @audit SECURITY: Authenticated via x-kiosk-api-key header; returns only location-specific bookings
* @audit Validate: Filters by kiosk's assigned location automatically
* @audit PERFORMANCE: Indexed queries on location_id, status, and start_time_utc
* @audit AUDIT: Kiosk booking access logged for operational monitoring
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { supabase } from '@/lib/supabase/client'
/**
* @description Public API - Retrieves basic availability information
* @description Public API endpoint providing basic location and service information for booking availability overview
* @param {NextRequest} request - HTTP request with required query parameter: location_id
* @returns {NextResponse} JSON with location details and list of active services, plus guidance to detailed availability endpoint
* @example GET /api/public/availability?location_id=123e4567-e89b-12d3-a456-426614174000
* @audit BUSINESS RULE: Provides high-level availability info; detailed time slots available via /api/availability/time-slots
* @audit SECURITY: Public endpoint; no authentication required; returns only active locations and services
* @audit Validate: Ensures location_id is provided and location is active
* @audit PERFORMANCE: Single query fetches location and services with indexed lookups
* @audit AUDIT: High-volume public endpoint; consider rate limiting in production
*/
export async function GET(request: NextRequest) {
try {

View File

@@ -4,7 +4,19 @@ import jsPDF from 'jspdf'
import { format } from 'date-fns'
import { es } from 'date-fns/locale'
/** @description Generate PDF receipt for booking */
/**
* @description Generates a PDF receipt for a completed booking
* @param {NextRequest} request - HTTP request (no body required for GET)
* @param {Object} params - Route parameters containing booking UUID
* @param {string} params.bookingId - The UUID of the booking to generate receipt for
* @returns {NextResponse} PDF file as binary response with Content-Type application/pdf
* @example GET /api/receipts/123e4567-e89b-12d3-a456-426614174000
* @audit BUSINESS RULE: Generates receipt with booking details, service info, pricing, and branding
* @audit SECURITY: Validates booking exists and user has access to view receipt
* @audit Validate: Ensures booking data is complete before PDF generation
* @audit PERFORMANCE: Single query fetches all related booking data (customer, service, staff, location)
* @audit AUDIT: Receipt generation is logged for audit trail
*/
export async function GET(
request: NextRequest,
{ params }: { params: { bookingId: string } }

View File

@@ -3,9 +3,17 @@ import { supabaseAdmin } from '@/lib/supabase/admin'
import Stripe from 'stripe'
/**
* @description Handle Stripe webhooks for payment intents and refunds
* @param {NextRequest} request - Raw Stripe webhook payload with signature
* @returns {NextResponse} Webhook processing result
* @description Processes Stripe webhook events for payment lifecycle management
* @param {NextRequest} request - HTTP request with raw Stripe webhook payload and stripe-signature header
* @returns {NextResponse} JSON confirming webhook receipt and processing status
* @example POST /api/webhooks/stripe (Stripe sends webhook payload)
* @audit BUSINESS RULE: Handles payment_intent.succeeded, payment_intent.payment_failed, and charge.refunded events
* @audit SECURITY: Verifies Stripe webhook signature using STRIPE_WEBHOOK_SECRET to prevent spoofing
* @audit Validate: Checks for duplicate event processing using event_id tracking
* @audit Validate: Returns 400 for missing signature or invalid signature
* @audit PERFORMANCE: Uses idempotency check to prevent duplicate processing
* @audit AUDIT: All webhook events logged in webhook_logs table with full payload
* @audit RELIABILITY: Critical for payment reconciliation - must be highly available
*/
export async function POST(request: NextRequest) {
try {

View File

@@ -40,9 +40,10 @@ export default function CitaPage() {
const date = searchParams.get('date')
const time = searchParams.get('time')
const customer_id = searchParams.get('customer_id')
const staff_id = searchParams.get('staff_id')
if (service_id && location_id && date && time) {
fetchBookingDetails(service_id, location_id, date, time)
fetchBookingDetails(service_id, location_id, date, time, staff_id)
}
if (customer_id) {
@@ -70,7 +71,7 @@ export default function CitaPage() {
}
}
const fetchBookingDetails = async (serviceId: string, locationId: string, date: string, time: string) => {
const fetchBookingDetails = async (serviceId: string, locationId: string, date: string, time: string, staffId?: string | null) => {
try {
const response = await fetch(`/api/availability/time-slots?location_id=${locationId}&service_id=${serviceId}&date=${date}`)
const data = await response.json()
@@ -86,7 +87,8 @@ export default function CitaPage() {
location_id: locationId,
date: date,
time: time,
startTime: `${date}T${time}`
startTime: `${date}T${time}`,
staff_id: staffId || null
})
} catch (error) {
console.error('Error fetching booking details:', error)
@@ -189,6 +191,7 @@ export default function CitaPage() {
location_id: bookingDetails.location_id,
start_time_utc: bookingDetails.startTime,
notes: formData.notas,
staff_id: bookingDetails.staff_id,
payment_method_id: 'mock_' + paymentMethod.cardNumber.slice(-4),
deposit_amount: depositAmount
})

View File

@@ -1,5 +1,13 @@
'use client'
/**
* @description Service selection and appointment booking page for The Boutique
* @audit BUSINESS RULE: Multi-step booking flow: service → datetime → confirm → client registration
* @audit SECURITY: Public endpoint with rate limiting recommended for availability checks
* @audit Validate: All steps must be completed before final booking submission
* @audit PERFORMANCE: Auto-fetches services, locations, and time slots based on selections
*/
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -23,8 +31,24 @@ interface Location {
timezone: string
}
type BookingStep = 'service' | 'datetime' | 'confirm' | 'client'
interface Staff {
id: string
display_name: string
role: string
}
type BookingStep = 'service' | 'datetime' | 'artist' | 'confirm' | 'client'
/**
* @description Booking flow page guiding customers through service selection, date/time, and confirmation
* @returns {JSX.Element} Multi-step booking wizard with service cards, date picker, time slots, and confirmation
* @audit BUSINESS RULE: Time slots filtered by service duration and staff availability
* @audit BUSINESS RULE: Time slots respect location business hours and existing bookings
* @audit SECURITY: Public endpoint; no authentication required for browsing
* @audit Validate: Service, location, date, and time required before proceeding
* @audit PERFORMANCE: Dynamic time slot loading based on service and date selection
* @audit AUDIT: Booking attempts logged for analytics and capacity planning
*/
export default function ServiciosPage() {
const [services, setServices] = useState<Service[]>([])
const [locations, setLocations] = useState<Location[]>([])
@@ -33,6 +57,8 @@ export default function ServiciosPage() {
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date())
const [timeSlots, setTimeSlots] = useState<any[]>([])
const [selectedTime, setSelectedTime] = useState<string>('')
const [availableArtists, setAvailableArtists] = useState<Staff[]>([])
const [selectedArtist, setSelectedArtist] = useState<string>('')
const [currentStep, setCurrentStep] = useState<BookingStep>('service')
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
@@ -90,6 +116,14 @@ export default function ServiciosPage() {
if (data.availability) {
setTimeSlots(data.availability)
}
const artistsResponse = await fetch(
`/api/availability/staff?location_id=${selectedLocation}&service_id=${selectedService}&date=${formattedDate}`
)
const artistsData = await artistsResponse.json()
if (artistsData.staff) {
setAvailableArtists(artistsData.staff)
}
} catch (error) {
console.error('Error fetching time slots:', error)
setErrors({ ...errors, timeSlots: 'Error al cargar horarios' })
@@ -111,6 +145,10 @@ export default function ServiciosPage() {
return selectedService && selectedLocation && selectedDate && selectedTime
}
const canProceedToArtist = () => {
return selectedService && selectedLocation && selectedDate && selectedTime
}
const handleProceed = () => {
setErrors({})
@@ -133,13 +171,33 @@ export default function ServiciosPage() {
setErrors({ time: 'Selecciona un horario' })
return
}
setCurrentStep('confirm')
if (availableArtists.length > 0) {
setCurrentStep('artist')
} else {
const params = new URLSearchParams({
service_id: selectedService,
location_id: selectedLocation,
date: format(selectedDate!, 'yyyy-MM-dd'),
time: selectedTime
})
window.location.href = `/booking/cita?${params.toString()}`
}
} else if (currentStep === 'artist') {
const params = new URLSearchParams({
service_id: selectedService,
location_id: selectedLocation,
date: format(selectedDate!, 'yyyy-MM-dd'),
time: selectedTime,
staff_id: selectedArtist
})
window.location.href = `/booking/cita?${params.toString()}`
} else if (currentStep === 'confirm') {
const params = new URLSearchParams({
service_id: selectedService,
location_id: selectedLocation,
date: format(selectedDate!, 'yyyy-MM-dd'),
time: selectedTime
time: selectedTime,
staff_id: selectedArtist
})
window.location.href = `/booking/cita?${params.toString()}`
}
@@ -148,8 +206,10 @@ export default function ServiciosPage() {
const handleStepBack = () => {
if (currentStep === 'datetime') {
setCurrentStep('service')
} else if (currentStep === 'confirm') {
} else if (currentStep === 'artist') {
setCurrentStep('datetime')
} else if (currentStep === 'confirm') {
setCurrentStep('artist')
}
}
@@ -267,7 +327,9 @@ export default function ServiciosPage() {
) : (
<div className="grid grid-cols-3 gap-2">
{timeSlots.map((slot, index) => {
const slotTime = new Date(slot.start_time)
const slotTimeUTC = new Date(slot.start_time)
// JavaScript automatically converts ISO string to local timezone
// Since Monterrey is UTC-6, this gives us the correct local time
return (
<Button
key={index}
@@ -276,7 +338,7 @@ export default function ServiciosPage() {
className={selectedTime === slot.start_time ? 'w-full' : ''}
style={selectedTime === slot.start_time ? { background: 'var(--deep-earth)' } : {}}
>
{format(slotTime, 'HH:mm', { locale: es })}
{format(slotTimeUTC, 'HH:mm', { locale: es })}
</Button>
)
})}
@@ -296,6 +358,66 @@ export default function ServiciosPage() {
</>
)}
{currentStep === 'artist' && (
<>
<Card style={{ background: 'var(--soft-cream)', borderColor: 'var(--mocha-taupe)', borderWidth: '1px' }}>
<CardHeader>
<CardTitle className="flex items-center gap-2" style={{ color: 'var(--charcoal-brown)' }}>
<User className="w-5 h-5" />
Seleccionar Artista
</CardTitle>
<CardDescription style={{ color: 'var(--charcoal-brown)', opacity: 0.7 }}>
{availableArtists.length > 0
? 'Elige el artista que prefieres para tu servicio'
: 'Se asignará automáticamente el primer artista disponible'}
</CardDescription>
</CardHeader>
<CardContent>
{availableArtists.length === 0 ? (
<div className="text-center py-8" style={{ color: 'var(--charcoal-brown)', opacity: 0.7 }}>
No hay artistas específicos disponibles. Se asignará automáticamente.
</div>
) : (
<div className="space-y-3">
{availableArtists.map((artist) => (
<div
key={artist.id}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedArtist === artist.id
? 'ring-2 ring-offset-2'
: 'hover:bg-gray-50'
}`}
style={{
borderColor: selectedArtist === artist.id ? 'var(--deep-earth)' : 'var(--mocha-taupe)',
background: selectedArtist === artist.id ? 'var(--bone-white)' : 'transparent'
}}
onClick={() => setSelectedArtist(artist.id)}
>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-medium"
style={{ background: 'var(--deep-earth)' }}
>
{artist.display_name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div>
<p className="font-medium" style={{ color: 'var(--charcoal-brown)' }}>
{artist.display_name}
</p>
<p className="text-sm capitalize" style={{ color: 'var(--charcoal-brown)', opacity: 0.7 }}>
{artist.role}
</p>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
)}
{currentStep === 'confirm' && selectedServiceData && selectedLocationData && selectedDate && selectedTime && (
<>
<Card style={{ background: 'var(--deep-earth)' }}>
@@ -314,10 +436,16 @@ export default function ServiciosPage() {
<p className="text-sm opacity-75">Fecha</p>
<p className="font-medium">{format(selectedDate, 'PPP', { locale: es })}</p>
</div>
<div>
<p className="text-sm opacity-75">Hora</p>
<p className="font-medium">{format(parseISO(selectedTime), 'HH:mm', { locale: es })}</p>
</div>
<div>
<p className="text-sm opacity-75">Hora</p>
<p className="font-medium">{format(new Date(selectedTime), 'HH:mm', { locale: es })}</p>
</div>
{selectedArtist && (
<div>
<p className="text-sm opacity-75">Artista</p>
<p className="font-medium">{availableArtists.find(a => a.id === selectedArtist)?.display_name || 'Seleccionado'}</p>
</div>
)}
<div>
<p className="text-sm opacity-75">Duración</p>
<p className="font-medium">{selectedServiceData.duration_minutes} minutos</p>

View File

@@ -7,7 +7,19 @@ import { BookingConfirmation } from '@/components/kiosk/BookingConfirmation'
import { WalkInFlow } from '@/components/kiosk/WalkInFlow'
import { Calendar, UserPlus, MapPin, Clock } from 'lucide-react'
/** @description Kiosk interface component for location-based check-in confirmations and walk-in booking creation. */
/**
* @description Kiosk interface component for location-based check-in confirmations and walk-in booking creation
* @param {Object} params - Route parameters containing the locationId
* @param {string} params.locationId - The UUID of the salon location this kiosk serves
* @returns {JSX.Element} Interactive kiosk interface with authentication, clock, and action cards
* @audit BUSINESS RULE: Kiosk enables customer self-service for check-in and walk-in bookings
* @audit BUSINESS RULE: Real-time clock displays in location's timezone for customer reference
* @audit SECURITY: Device authentication via API key required before any operations
* @audit SECURITY: Kiosk mode has no user authentication - relies on device-level security
* @audit Validate: Location must be active and have associated kiosk device registered
* @audit PERFORMANCE: Single-page app with view-based rendering (no page reloads)
* @audit AUDIT: Kiosk operations logged for security and operational monitoring
*/
export default function KioskPage({ params }: { params: { locationId: string } }) {
const [apiKey, setApiKey] = useState<string | null>(null)
const [location, setLocation] = useState<any>(null)

View File

@@ -5,8 +5,15 @@ import { useRouter, usePathname } from 'next/navigation'
import { useAuth } from '@/lib/auth/context'
/**
* AuthGuard component that shows loading state while authentication is being determined
* Redirect logic is now handled by AuthProvider to avoid conflicts
* @description Authentication guard component that protects routes requiring login
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Child components to render when authenticated
* @returns {JSX.Element} Loading state while auth is determined, or children when authenticated
* @audit BUSINESS RULE: AuthGuard is a client-side guard for protected routes
* @audit SECURITY: Prevents rendering protected content until authentication verified
* @audit Validate: Loading state shown while auth provider determines user session
* @audit PERFORMANCE: No API calls - relies on AuthProvider's cached session state
* @audit Note: Actual redirect logic handled by AuthProvider to avoid conflicts
*/
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { loading: authLoading } = useAuth()

View File

@@ -10,6 +10,21 @@ interface DatePickerProps {
disabled?: boolean
}
/**
* @description Custom date picker component for booking flow with month navigation and date selection
* @param {DatePickerProps} props - Component props including selected date, selection callback, and constraints
* @param {Date | null} props.selectedDate - Currently selected date value
* @param {(date: Date) => void} props.onDateSelect - Callback invoked when user selects a date
* @param {Date} props.minDate - Optional minimum selectable date (defaults to today if not provided)
* @param {boolean} props.disabled - Optional flag to disable all interactions
* @returns {JSX.Element} Interactive calendar grid with month navigation and date selection
* @audit BUSINESS RULE: Calendar starts on Monday (Spanish locale convention)
* @audit BUSINESS RULE: Disabled dates cannot be selected (past dates via minDate)
* @audit SECURITY: Client-side only component with no external data access
* @audit Validate: minDate is enforced via date comparison before selection
* @audit PERFORMANCE: Uses date-fns for efficient date calculations
* @audit UI: Today's date indicated with visual marker (dot indicator)
*/
export default function DatePicker({ selectedDate, onDateSelect, minDate, disabled }: DatePickerProps) {
const [currentMonth, setCurrentMonth] = useState(selectedDate ? new Date(selectedDate) : new Date())

View File

@@ -1,5 +1,5 @@
/**
* @description Calendar view component with drag-and-drop rescheduling functionality
* @description Calendar view component with drag-and-drop rescheduling and booking creation
* @audit BUSINESS RULE: Calendar shows only bookings for selected date and filters
* @audit SECURITY: Component requires authenticated admin/manager user context
* @audit PERFORMANCE: Auto-refresh every 30 seconds for real-time updates
@@ -16,7 +16,10 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Calendar, ChevronLeft, ChevronRight, Clock, User, MapPin } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Calendar, ChevronLeft, ChevronRight, Clock, User, MapPin, Plus } from 'lucide-react'
import {
DndContext,
closestCenter,
@@ -36,6 +39,7 @@ import {
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { checkStaffCanPerformService, checkForConflicts, rescheduleBooking } from '@/lib/calendar-utils'
interface Booking {
id: string
@@ -68,6 +72,7 @@ interface Staff {
id: string
display_name: string
role: string
location_id: string
}
interface Location {
@@ -163,9 +168,10 @@ interface TimeSlotProps {
bookings: Booking[]
staffId: string
onBookingDrop?: (bookingId: string, newTime: string, staffId: string) => void
onSlotClick?: (time: Date, staffId: string) => void
}
function TimeSlot({ time, bookings, staffId, onBookingDrop }: TimeSlotProps) {
function TimeSlot({ time, bookings, staffId, onBookingDrop, onSlotClick }: TimeSlotProps) {
const timeBookings = bookings.filter(booking =>
booking.staff.id === staffId &&
parseISO(booking.startTime).getHours() === time.getHours() &&
@@ -173,7 +179,15 @@ function TimeSlot({ time, bookings, staffId, onBookingDrop }: TimeSlotProps) {
)
return (
<div className="border-r border-gray-200 min-h-[60px] relative">
<div
className="border-r border-gray-200 min-h-[60px] relative"
onClick={() => onSlotClick && timeBookings.length === 0 && onSlotClick(time, staffId)}
>
{timeBookings.length === 0 && onSlotClick && (
<div className="absolute inset-0 hover:bg-blue-50 cursor-pointer transition-colors flex items-center justify-center opacity-0 hover:opacity-100">
<Plus className="w-6 h-6 text-blue-400" />
</div>
)}
{timeBookings.map(booking => (
<SortableBooking
key={booking.id}
@@ -190,34 +204,12 @@ interface StaffColumnProps {
bookings: Booking[]
businessHours: { start: string, end: string }
onBookingDrop?: (bookingId: string, newTime: string, staffId: string) => void
onSlotClick?: (time: Date, staffId: string) => void
}
function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: StaffColumnProps) {
function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop, onSlotClick }: StaffColumnProps) {
const staffBookings = bookings.filter(booking => booking.staff.id === staff.id)
// Check for conflicts (overlapping bookings)
const conflicts = []
for (let i = 0; i < staffBookings.length; i++) {
for (let j = i + 1; j < staffBookings.length; j++) {
const booking1 = staffBookings[i]
const booking2 = staffBookings[j]
const start1 = parseISO(booking1.startTime)
const end1 = parseISO(booking1.endTime)
const start2 = parseISO(booking2.startTime)
const end2 = parseISO(booking2.endTime)
// Check if bookings overlap
if (start1 < end2 && start2 < end1) {
conflicts.push({
booking1: booking1.id,
booking2: booking2.id,
time: Math.min(start1.getTime(), start2.getTime())
})
}
}
}
const timeSlots = []
const [startHour, startMinute] = businessHours.start.split(':').map(Number)
@@ -231,7 +223,7 @@ function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: St
while (currentTime < endTime) {
timeSlots.push(new Date(currentTime))
currentTime = addMinutes(currentTime, 15) // 15-minute slots
currentTime = addMinutes(currentTime, 15)
}
return (
@@ -247,15 +239,6 @@ function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: St
</div>
<div className="relative">
{/* Conflict indicator */}
{conflicts.length > 0 && (
<div className="absolute top-2 right-2 z-10">
<div className="bg-red-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1">
{conflicts.length} conflicto{conflicts.length > 1 ? 's' : ''}
</div>
</div>
)}
{timeSlots.map((timeSlot, index) => (
<div key={index} className="border-b border-gray-100 min-h-[60px]">
<TimeSlot
@@ -263,6 +246,7 @@ function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: St
bookings={staffBookings}
staffId={staff.id}
onBookingDrop={onBookingDrop}
onSlotClick={onSlotClick}
/>
</div>
))}
@@ -288,6 +272,121 @@ export default function CalendarView() {
const [rescheduleError, setRescheduleError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const [showCreateBooking, setShowCreateBooking] = useState(false)
const [createBookingData, setCreateBookingData] = useState<{
time: Date | null
staffId: string | null
customerId: string
serviceId: string
locationId: string
notes: string
}>({
time: null,
staffId: null,
customerId: '',
serviceId: '',
locationId: '',
notes: ''
})
const [createBookingError, setCreateBookingError] = useState<string | null>(null)
const [services, setServices] = useState<any[]>([])
const [customers, setCustomers] = useState<any[]>([])
const fetchServices = async () => {
try {
const response = await fetch('/api/services')
const data = await response.json()
if (data.success) {
setServices(data.services || [])
}
} catch (error) {
console.error('Error fetching services:', error)
}
}
const fetchCustomers = async () => {
try {
const response = await fetch('/api/customers')
const data = await response.json()
if (data.success) {
setCustomers(data.customers || [])
}
} catch (error) {
console.error('Error fetching customers:', error)
}
}
useEffect(() => {
fetchServices()
fetchCustomers()
}, [])
const handleSlotClick = (time: Date, staffId: string) => {
const locationId = selectedLocations.length > 0 ? selectedLocations[0] : (calendarData?.locations[0]?.id || '')
setCreateBookingData({
time,
staffId,
customerId: '',
serviceId: '',
locationId,
notes: ''
})
setShowCreateBooking(true)
setCreateBookingError(null)
}
const handleCreateBooking = async (e: React.FormEvent) => {
e.preventDefault()
setCreateBookingError(null)
if (!createBookingData.time || !createBookingData.staffId || !createBookingData.customerId || !createBookingData.serviceId || !createBookingData.locationId) {
setCreateBookingError('Todos los campos son obligatorios')
return
}
try {
setLoading(true)
const startTimeUtc = createBookingData.time.toISOString()
const response = await fetch('/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
customer_id: createBookingData.customerId,
service_id: createBookingData.serviceId,
location_id: createBookingData.locationId,
start_time_utc: startTimeUtc,
staff_id: createBookingData.staffId,
notes: createBookingData.notes || null
}),
})
const result = await response.json()
if (result.success) {
setShowCreateBooking(false)
setCreateBookingData({
time: null,
staffId: null,
customerId: '',
serviceId: '',
locationId: '',
notes: ''
})
await fetchCalendarData()
} else {
setCreateBookingError(result.error || 'Error al crear la cita')
}
} catch (error) {
console.error('Error creating booking:', error)
setCreateBookingError('Error de conexión al crear la cita')
} finally {
setLoading(false)
}
}
const fetchCalendarData = useCallback(async () => {
setLoading(true)
try {
@@ -325,11 +424,10 @@ export default function CalendarView() {
fetchCalendarData()
}, [fetchCalendarData])
// Auto-refresh every 30 seconds for real-time updates
useEffect(() => {
const interval = setInterval(() => {
fetchCalendarData()
}, 30000) // 30 seconds
}, 30000)
return () => clearInterval(interval)
}, [fetchCalendarData])
@@ -353,34 +451,22 @@ export default function CalendarView() {
setCurrentDate(new Date())
}
const handleStaffFilter = (staffIds: string[]) => {
setSelectedStaff(staffIds)
}
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (!over) return
const bookingId = active.id as string
const targetStaffId = over.id as string
const targetInfo = over.id as string
// Find the booking
const booking = calendarData?.bookings.find(b => b.id === bookingId)
if (!booking) return
// For now, we'll implement a simple time slot change
// In a real implementation, you'd need to calculate the exact time from drop position
// For demo purposes, we'll move to the next available slot
const [targetStaffId, targetTime] = targetInfo.includes('-') ? targetInfo.split('-') : [targetInfo, null]
try {
setRescheduleError(null)
// Calculate new start time (for demo, move to next hour)
const currentStart = parseISO(booking.startTime)
const newStartTime = new Date(currentStart.getTime() + (60 * 60 * 1000)) // +1 hour
const currentStart = parseISO(bookingId)
const newStartTime = new Date(currentStart.getTime() + (60 * 60 * 1000))
// Call the reschedule API
const response = await fetch(`/api/aperture/bookings/${bookingId}/reschedule`, {
method: 'POST',
headers: {
@@ -389,14 +475,13 @@ export default function CalendarView() {
body: JSON.stringify({
bookingId,
newStartTime: newStartTime.toISOString(),
newStaffId: targetStaffId !== booking.staff.id ? targetStaffId : undefined,
newStaffId: targetStaffId,
}),
})
const result = await response.json()
if (result.success) {
// Refresh calendar data
await fetchCalendarData()
setRescheduleError(null)
} else {
@@ -423,7 +508,136 @@ export default function CalendarView() {
return (
<div className="space-y-4">
{/* Header Controls */}
<Dialog open={showCreateBooking} onOpenChange={setShowCreateBooking}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Crear Nueva Cita</DialogTitle>
<DialogDescription>
{createBookingData.time && (
<span className="text-sm">
{format(createBookingData.time, 'EEEE, d MMMM yyyy HH:mm', { locale: es })}
</span>
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateBooking} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="customer">Cliente</Label>
<Select
value={createBookingData.customerId}
onValueChange={(value) => setCreateBookingData({ ...createBookingData, customerId: value })}
>
<SelectTrigger id="customer">
<SelectValue placeholder="Seleccionar cliente" />
</SelectTrigger>
<SelectContent>
{customers.map(customer => (
<SelectItem key={customer.id} value={customer.id}>
{customer.first_name} {customer.last_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="service">Servicio</Label>
<Select
value={createBookingData.serviceId}
onValueChange={(value) => setCreateBookingData({ ...createBookingData, serviceId: value })}
>
<SelectTrigger id="service">
<SelectValue placeholder="Seleccionar servicio" />
</SelectTrigger>
<SelectContent>
{services.filter(s => s.location_id === createBookingData.locationId).map(service => (
<SelectItem key={service.id} value={service.id}>
{service.name} ({service.duration_minutes} min) - ${service.base_price}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="location">Ubicación</Label>
<Select
value={createBookingData.locationId}
onValueChange={(value) => setCreateBookingData({ ...createBookingData, locationId: value })}
>
<SelectTrigger id="location">
<SelectValue placeholder="Seleccionar ubicación" />
</SelectTrigger>
<SelectContent>
{calendarData.locations.map(location => (
<SelectItem key={location.id} value={location.id}>
{location.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="staff">Staff Asignado</Label>
<Select
value={createBookingData.staffId || ''}
onValueChange={(value) => setCreateBookingData({ ...createBookingData, staffId: value })}
>
<SelectTrigger id="staff">
<SelectValue placeholder="Seleccionar staff" />
</SelectTrigger>
<SelectContent>
{calendarData.staff.filter(staffMember => staffMember.location_id === createBookingData.locationId).map(staffMember => (
<SelectItem key={staffMember.id} value={staffMember.id}>
{staffMember.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notas</Label>
<Input
id="notes"
value={createBookingData.notes}
onChange={(e) => setCreateBookingData({ ...createBookingData, notes: e.target.value })}
placeholder="Notas adicionales (opcional)"
/>
</div>
{createBookingError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800 text-sm">{createBookingError}</p>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setShowCreateBooking(false)}
disabled={loading}
>
Cancelar
</Button>
<Button
type="submit"
disabled={loading}
>
{loading ? 'Creando...' : 'Crear Cita'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
@@ -459,11 +673,7 @@ export default function CalendarView() {
<Select
value={selectedLocations.length === 0 ? 'all' : selectedLocations[0]}
onValueChange={(value) => {
if (value === 'all') {
setSelectedLocations([])
} else {
setSelectedLocations([value])
}
value === 'all' ? setSelectedLocations([]) : setSelectedLocations([value])
}}
>
<SelectTrigger className="w-48">
@@ -485,11 +695,7 @@ export default function CalendarView() {
<Select
value={selectedStaff.length === 0 ? 'all' : selectedStaff[0]}
onValueChange={(value) => {
if (value === 'all') {
setSelectedStaff([])
} else {
setSelectedStaff([value])
}
value === 'all' ? setSelectedStaff([]) : setSelectedStaff([value])
}}
>
<SelectTrigger className="w-48">
@@ -515,7 +721,6 @@ export default function CalendarView() {
</CardContent>
</Card>
{/* Calendar Grid */}
<Card>
<CardContent className="p-0">
<DndContext
@@ -524,7 +729,6 @@ export default function CalendarView() {
onDragEnd={handleDragEnd}
>
<div className="flex">
{/* Time Column */}
<div className="w-20 bg-gray-50 border-r">
<div className="p-3 border-b font-semibold text-sm text-center">
Hora
@@ -533,7 +737,7 @@ export default function CalendarView() {
const timeSlots = []
const [startHour] = calendarData.businessHours.start.split(':').map(Number)
const [endHour] = calendarData.businessHours.end.split(':').map(Number)
for (let hour = startHour; hour <= endHour; hour++) {
timeSlots.push(
<div key={hour} className="border-b border-gray-100 p-2 text-xs text-center min-h-[60px] flex items-center justify-center">
@@ -546,7 +750,6 @@ export default function CalendarView() {
})()}
</div>
{/* Staff Columns */}
<div className="flex flex-1 overflow-x-auto">
{calendarData.staff.map(staff => (
<StaffColumn
@@ -555,6 +758,7 @@ export default function CalendarView() {
date={currentDate}
bookings={calendarData.bookings}
businessHours={calendarData.businessHours}
onSlotClick={handleSlotClick}
/>
))}
</div>
@@ -564,4 +768,4 @@ export default function CalendarView() {
</Card>
</div>
)
}
}

View File

@@ -1,5 +1,13 @@
'use client'
/**
* @description Kiosk booking confirmation interface for customers arriving with appointments
* @audit BUSINESS RULE: Customers confirm appointments by entering 6-character short ID
* @audit SECURITY: Authenticated via x-kiosk-api-key header for all API calls
* @audit Validate: Only pending bookings can be confirmed; already confirmed shows warning
* @audit PERFORMANCE: Large touch-friendly input optimized for self-service kiosks
*/
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -12,7 +20,17 @@ interface BookingConfirmationProps {
}
/**
* BookingConfirmation component that allows confirming a booking by short ID.
* @description Booking confirmation component for kiosk self-service check-in
* @param {string} apiKey - Kiosk API key for authentication
* @param {Function} onConfirm - Callback when booking is successfully confirmed
* @param {Function} onCancel - Callback when customer cancels the process
* @returns {JSX.Element} Input form for 6-character booking code with confirmation options
* @audit BUSINESS RULE: Search by short_id (6 characters) for quick customer lookup
* @audit BUSINESS RULE: Only pending bookings can be confirmed; other statuses show error
* @audit SECURITY: All API calls require valid kiosk API key in header
* @audit Validate: Short ID must be exactly 6 characters
* @audit PERFORMANCE: Single API call to fetch booking by short_id
* @audit AUDIT: Booking confirmations logged through /api/kiosk/bookings/[shortId]/confirm
*/
export function BookingConfirmation({ apiKey, onConfirm, onCancel }: BookingConfirmationProps) {
const [shortId, setShortId] = useState('')

View File

@@ -1,5 +1,13 @@
'use client'
/**
* @description Kiosk walk-in booking flow for in-store service reservations
* @audit BUSINESS RULE: Walk-in flow designed for touch screen with large buttons and simple navigation
* @audit SECURITY: Authenticated via x-kiosk-api-key header for all API calls
* @audit Validate: Multi-step flow with service → customer → confirm → success states
* @audit PERFORMANCE: Optimized for offline-capable touch interface
*/
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -14,7 +22,17 @@ interface WalkInFlowProps {
}
/**
* WalkInFlow component that manages the walk-in booking process in steps.
* @description Walk-in booking flow component for kiosk terminals
* @param {string} apiKey - Kiosk API key for authentication
* @param {Function} onComplete - Callback when walk-in booking is completed successfully
* @param {Function} onCancel - Callback when customer cancels the walk-in process
* @returns {JSX.Element} Multi-step wizard for service selection, customer info, and confirmation
* @audit BUSINESS RULE: 4-step flow: services → customer info → resource assignment → success
* @audit BUSINESS RULE: Resources auto-assigned based on availability and service priority
* @audit SECURITY: All API calls require valid kiosk API key in header
* @audit Validate: Customer name and service selection required before booking
* @audit PERFORMANCE: Single-page flow optimized for touch interaction
* @audit AUDIT: Walk-in bookings logged through /api/kiosk/walkin endpoint
*/
export function WalkInFlow({ apiKey, onComplete, onCancel }: WalkInFlowProps) {
const [step, setStep] = useState<'services' | 'customer' | 'confirm' | 'success'>('services')

View File

@@ -0,0 +1,388 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Plus, Edit, Trash2, Smartphone, MapPin, Key, Wifi } from 'lucide-react'
interface Kiosk {
id: string
device_name: string
display_name: string
api_key: string
ip_address?: string
is_active: boolean
created_at: string
location?: {
id: string
name: string
address: string
}
}
interface Location {
id: string
name: string
address: string
}
export default function KiosksManagement() {
const [kiosks, setKiosks] = useState<Kiosk[]>([])
const [locations, setLocations] = useState<Location[]>([])
const [loading, setLoading] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingKiosk, setEditingKiosk] = useState<Kiosk | null>(null)
const [showApiKey, setShowApiKey] = useState<string | null>(null)
const [formData, setFormData] = useState({
device_name: '',
display_name: '',
location_id: '',
ip_address: ''
})
useEffect(() => {
fetchKiosks()
fetchLocations()
}, [])
const fetchKiosks = async () => {
setLoading(true)
try {
const response = await fetch('/api/aperture/kiosks')
const data = await response.json()
if (data.success) {
setKiosks(data.kiosks)
}
} catch (error) {
console.error('Error fetching kiosks:', error)
} finally {
setLoading(false)
}
}
const fetchLocations = async () => {
try {
const response = await fetch('/api/aperture/locations')
const data = await response.json()
if (data.success) {
setLocations(data.locations || [])
}
} catch (error) {
console.error('Error fetching locations:', error)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const url = editingKiosk
? `/api/aperture/kiosks/${editingKiosk.id}`
: '/api/aperture/kiosks'
const method = editingKiosk ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
const data = await response.json()
if (data.success) {
await fetchKiosks()
setDialogOpen(false)
setEditingKiosk(null)
setFormData({ device_name: '', display_name: '', location_id: '', ip_address: '' })
} else {
alert(data.error || 'Error saving kiosk')
}
} catch (error) {
console.error('Error saving kiosk:', error)
alert('Error saving kiosk')
}
}
const handleEdit = (kiosk: Kiosk) => {
setEditingKiosk(kiosk)
setFormData({
device_name: kiosk.device_name,
display_name: kiosk.display_name,
location_id: kiosk.location?.id || '',
ip_address: kiosk.ip_address || ''
})
setDialogOpen(true)
}
const handleDelete = async (kiosk: Kiosk) => {
if (!confirm(`¿Estás seguro de que quieres eliminar el kiosko "${kiosk.device_name}"?`)) {
return
}
try {
const response = await fetch(`/api/aperture/kiosks/${kiosk.id}`, {
method: 'DELETE'
})
const data = await response.json()
if (data.success) {
await fetchKiosks()
} else {
alert(data.error || 'Error deleting kiosk')
}
} catch (error) {
console.error('Error deleting kiosk:', error)
alert('Error deleting kiosk')
}
}
const toggleKioskStatus = async (kiosk: Kiosk) => {
try {
const response = await fetch(`/api/aperture/kiosks/${kiosk.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...kiosk,
is_active: !kiosk.is_active
})
})
const data = await response.json()
if (data.success) {
await fetchKiosks()
} else {
alert(data.error || 'Error updating kiosk status')
}
} catch (error) {
console.error('Error toggling kiosk status:', error)
}
}
const copyApiKey = (apiKey: string) => {
navigator.clipboard.writeText(apiKey)
setShowApiKey(apiKey)
setTimeout(() => setShowApiKey(null), 2000)
}
const openCreateDialog = () => {
setEditingKiosk(null)
setFormData({ device_name: '', display_name: '', location_id: '', ip_address: '' })
setDialogOpen(true)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">Gestión de Kioskos</h2>
<p className="text-gray-600">Administra los dispositivos kiosko para check-in</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="w-4 h-4 mr-2" />
Nuevo Kiosko
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="w-5 h-5" />
Dispositivos Kiosko
</CardTitle>
<CardDescription>
{kiosks.length} dispositivos registrados
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="text-center py-8">Cargando kioskos...</div>
) : kiosks.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No hay kioskos registrados. Agrega uno para comenzar.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Dispositivo</TableHead>
<TableHead>Ubicación</TableHead>
<TableHead>IP</TableHead>
<TableHead>API Key</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{kiosks.map((kiosk) => (
<TableRow key={kiosk.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
<Smartphone className="w-5 h-5 text-gray-600" />
</div>
<div>
<div className="font-medium">{kiosk.device_name}</div>
{kiosk.display_name !== kiosk.device_name && (
<div className="text-sm text-gray-500">{kiosk.display_name}</div>
)}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<MapPin className="w-3 h-3" />
{kiosk.location?.name || 'Sin ubicación'}
</div>
</TableCell>
<TableCell>
{kiosk.ip_address ? (
<div className="flex items-center gap-1 text-sm">
<Wifi className="w-3 h-3" />
{kiosk.ip_address}
</div>
) : (
<span className="text-gray-400">Sin IP</span>
)}
</TableCell>
<TableCell>
<button
onClick={() => copyApiKey(kiosk.api_key)}
className="flex items-center gap-1 text-sm font-mono bg-gray-100 px-2 py-1 rounded hover:bg-gray-200 transition-colors"
title="Click para copiar"
>
<Key className="w-3 h-3" />
{showApiKey === kiosk.api_key ? 'Copiado!' : `${kiosk.api_key.slice(0, 8)}...`}
</button>
</TableCell>
<TableCell>
<Badge
variant={kiosk.is_active ? 'default' : 'secondary'}
className={kiosk.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}
>
{kiosk.is_active ? 'Activo' : 'Inactivo'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => toggleKioskStatus(kiosk)}
>
{kiosk.is_active ? 'Desactivar' : 'Activar'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(kiosk)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(kiosk)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{editingKiosk ? 'Editar Kiosko' : 'Nuevo Kiosko'}
</DialogTitle>
<DialogDescription>
{editingKiosk ? 'Modifica la información del kiosko' : 'Agrega un nuevo dispositivo kiosko'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="device_name" className="text-right">
Nombre *
</Label>
<Input
id="device_name"
value={formData.device_name}
onChange={(e) => setFormData({...formData, device_name: e.target.value})}
className="col-span-3"
placeholder="Ej. Kiosko Principal"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="display_name" className="text-right">
Display
</Label>
<Input
id="display_name"
value={formData.display_name}
onChange={(e) => setFormData({...formData, display_name: e.target.value})}
className="col-span-3"
placeholder="Nombre a mostrar"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="location_id" className="text-right">
Ubicación *
</Label>
<Select
value={formData.location_id}
onValueChange={(value) => setFormData({...formData, location_id: value})}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Seleccionar ubicación" />
</SelectTrigger>
<SelectContent>
{locations.map((location) => (
<SelectItem key={location.id} value={location.id}>
{location.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="ip_address" className="text-right">
IP
</Label>
<Input
id="ip_address"
value={formData.ip_address}
onChange={(e) => setFormData({...formData, ip_address: e.target.value})}
className="col-span-3"
placeholder="192.168.1.100"
/>
</div>
</div>
<DialogFooter>
<Button type="submit">
{editingKiosk ? 'Actualizar' : 'Crear'} Kiosko
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -2,7 +2,17 @@
import React, { useState, useEffect } from 'react'
/** @description Elegant loading screen with Anchor 23 branding */
/**
* @description Elegant branded loading screen with Anchor:23 logo reveal animation
* @param {Object} props - Component props
* @param {() => void} props.onComplete - Callback invoked when loading animation completes
* @returns {JSX.Element} Full-screen loading overlay with animated logo and progress bar
* @audit BUSINESS RULE: Loading screen provides brand consistency during app initialization
* @audit SECURITY: Client-side only animation with no external data access
* @audit Validate: onComplete callback triggers app state transition to loaded
* @audit PERFORMANCE: Uses CSS animations for smooth GPU-accelerated transitions
* @audit UI: Features SVG logo with clip-path reveal animation and gradient progress bar
*/
export function LoadingScreen({ onComplete }: { onComplete: () => void }) {
const [progress, setProgress] = useState(0)
const [showLogo, setShowLogo] = useState(false)

View File

@@ -1,5 +1,13 @@
'use client'
/**
* @description Payroll management interface for calculating and tracking staff compensation
* @audit BUSINESS RULE: Payroll includes base salary, service commissions (10%), and tips (5%)
* @audit SECURITY: Requires authenticated admin/manager role via useAuth hook
* @audit Validate: Payroll period must have valid start and end dates
* @audit AUDIT: Payroll calculations logged through /api/aperture/payroll endpoint
*/
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -42,6 +50,16 @@ interface PayrollCalculation {
hours_worked: number
}
/**
* @description Payroll management component with calculation, listing, and reporting features
* @returns {JSX.Element} Complete payroll interface with period selection, staff filtering, and calculation modal
* @audit BUSINESS RULE: Calculates payroll from completed bookings within the selected period
* @audit BUSINESS RULE: Commission is 10% of service revenue, tips are 5% of service revenue
* @audit SECURITY: Requires authenticated admin/manager role; staff cannot access payroll
* @audit Validate: Ensures period dates are valid before calculation
* @audit PERFORMANCE: Auto-sets default period to current month on mount
* @audit AUDIT: Payroll records stored and retrievable for financial reporting
*/
export default function PayrollManagement() {
const { user } = useAuth()
const [payrollRecords, setPayrollRecords] = useState<PayrollRecord[]>([])

View File

@@ -1,5 +1,14 @@
'use client'
/**
* @description Point of Sale (POS) interface for processing service and product sales with multiple payment methods
* @audit BUSINESS RULE: POS handles service/product sales with cash, card, transfer, giftcard, and membership payments
* @audit SECURITY: Requires authenticated staff member (cashier) via useAuth hook
* @audit Validate: Payment amounts must match cart total before processing
* @audit AUDIT: All sales transactions logged through /api/aperture/pos endpoint
* @audit PERFORMANCE: Optimized for touch interface with large touch targets
*/
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -39,6 +48,17 @@ interface SaleResult {
receipt: any
}
/**
* @description Point of Sale component with cart management, customer selection, and multi-payment support
* @returns {JSX.Element} Complete POS interface with service/product catalog, cart, and payment processing
* @audit BUSINESS RULE: Cart items can be services or products with quantity management
* @audit BUSINESS RULE: Multiple partial payments supported (split payments)
* @audit SECURITY: Requires authenticated staff member; validates user permissions
* @audit Validate: Cart cannot be empty when processing payment
* @audit Validate: Payment total must equal or exceed cart subtotal
* @audit PERFORMANCE: Auto-fetches services, products, and customers on mount
* @audit AUDIT: Sales processed through /api/aperture/pos with full transaction logging
*/
export default function POSSystem() {
const { user } = useAuth()
const [cart, setCart] = useState<POSItem[]>([])

View File

@@ -0,0 +1,447 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Plus, Edit, Trash2, Clock, Coffee, Calendar } from 'lucide-react'
interface StaffSchedule {
id: string
staff_id: string
date: string
start_time: string
end_time: string
is_available: boolean
reason?: string
}
interface Staff {
id: string
display_name: string
role: string
}
const DAYS_OF_WEEK = [
{ key: 'monday', label: 'Lunes' },
{ key: 'tuesday', label: 'Martes' },
{ key: 'wednesday', label: 'Miércoles' },
{ key: 'thursday', label: 'Jueves' },
{ key: 'friday', label: 'Viernes' },
{ key: 'saturday', label: 'Sábado' },
{ key: 'sunday', label: 'Domingo' }
]
const TIME_SLOTS = Array.from({ length: 24 * 2 }, (_, i) => {
const hour = Math.floor(i / 2)
const minute = (i % 2) * 30
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
})
export default function ScheduleManagement() {
const [staff, setStaff] = useState<Staff[]>([])
const [selectedStaff, setSelectedStaff] = useState<string>('')
const [schedule, setSchedule] = useState<StaffSchedule[]>([])
const [loading, setLoading] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingSchedule, setEditingSchedule] = useState<StaffSchedule | null>(null)
const [formData, setFormData] = useState({
date: '',
start_time: '09:00',
end_time: '17:00',
is_available: true,
reason: ''
})
useEffect(() => {
fetchStaff()
}, [])
useEffect(() => {
if (selectedStaff) {
fetchSchedule()
}
}, [selectedStaff])
const fetchStaff = async () => {
try {
const response = await fetch('/api/aperture/staff')
const data = await response.json()
if (data.success) {
setStaff(data.staff)
}
} catch (error) {
console.error('Error fetching staff:', error)
}
}
const fetchSchedule = async () => {
if (!selectedStaff) return
setLoading(true)
try {
const today = new Date()
const startDate = today.toISOString().split('T')[0]
const endDate = new Date(today.setDate(today.getDate() + 30)).toISOString().split('T')[0]
const response = await fetch(
`/api/aperture/staff/schedule?staff_id=${selectedStaff}&start_date=${startDate}&end_date=${endDate}`
)
const data = await response.json()
if (data.success) {
setSchedule(data.availability || [])
}
} catch (error) {
console.error('Error fetching schedule:', error)
} finally {
setLoading(false)
}
}
const generateWeeklySchedule = async () => {
if (!selectedStaff) return
const weeklyData = DAYS_OF_WEEK.map((day, index) => {
const date = new Date()
date.setDate(date.getDate() + ((index + 7 - date.getDay()) % 7))
const dateStr = date.toISOString().split('T')[0]
const isWeekend = day.key === 'saturday' || day.key === 'sunday'
const startTime = isWeekend ? '10:00' : '09:00'
const endTime = isWeekend ? '15:00' : '17:00'
return {
staff_id: selectedStaff,
date: dateStr,
start_time: startTime,
end_time: endTime,
is_available: !isWeekend,
reason: isWeekend ? 'Fin de semana' : undefined
}
})
try {
for (const day of weeklyData) {
await fetch('/api/aperture/staff/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(day)
})
}
await fetchSchedule()
alert('Horario semanal generado exitosamente')
} catch (error) {
console.error('Error generating weekly schedule:', error)
alert('Error al generar el horario')
}
}
const addBreakToSchedule = async (scheduleId: string, breakStart: string, breakEnd: string) => {
try {
await fetch('/api/aperture/staff/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
staff_id: selectedStaff,
date: schedule.find(s => s.id === scheduleId)?.date,
start_time: breakStart,
end_time: breakEnd,
is_available: false,
reason: 'Break de 30 min'
})
})
await fetchSchedule()
} catch (error) {
console.error('Error adding break:', error)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await fetch('/api/aperture/staff/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
staff_id: selectedStaff,
...formData
})
})
await fetchSchedule()
setDialogOpen(false)
setEditingSchedule(null)
setFormData({ date: '', start_time: '09:00', end_time: '17:00', is_available: true, reason: '' })
} catch (error) {
console.error('Error saving schedule:', error)
alert('Error al guardar el horario')
}
}
const handleDelete = async (scheduleId: string) => {
if (!confirm('¿Eliminar este horario?')) return
try {
await fetch(`/api/aperture/staff/schedule?id=${scheduleId}`, {
method: 'DELETE'
})
await fetchSchedule()
} catch (error) {
console.error('Error deleting schedule:', error)
}
}
const calculateWorkingHours = (schedules: StaffSchedule[]) => {
return schedules.reduce((total, s) => {
if (!s.is_available) return total
const start = parseInt(s.start_time.split(':')[0]) * 60 + parseInt(s.start_time.split(':')[1])
const end = parseInt(s.end_time.split(':')[0]) * 60 + parseInt(s.end_time.split(':')[1])
return total + (end - start)
}, 0)
}
const getScheduleForDate = (date: string) => {
return schedule.filter(s => s.date === date && s.is_available)
}
const getBreaksForDate = (date: string) => {
return schedule.filter(s => s.date === date && !s.is_available && s.reason === 'Break de 30 min')
}
const selectedStaffData = staff.find(s => s.id === selectedStaff)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">Gestión de Horarios</h2>
<p className="text-gray-600">Administra horarios y breaks del staff</p>
</div>
<div className="flex gap-2">
{selectedStaff && (
<>
<Button variant="outline" onClick={generateWeeklySchedule}>
<Calendar className="w-4 h-4 mr-2" />
Generar Semana
</Button>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Agregar Día
</Button>
</>
)}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Seleccionar Staff</CardTitle>
<CardDescription>Selecciona un miembro del equipo para ver y gestionar su horario</CardDescription>
</CardHeader>
<CardContent>
<Select value={selectedStaff} onValueChange={setSelectedStaff}>
<SelectTrigger className="w-full max-w-md">
<SelectValue placeholder="Seleccionar staff" />
</SelectTrigger>
<SelectContent>
{staff.map((member) => (
<SelectItem key={member.id} value={member.id}>
{member.display_name} ({member.role})
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{selectedStaff && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Horario de {selectedStaffData?.display_name}
</CardTitle>
<CardDescription>
Total horas programadas: {(calculateWorkingHours(schedule) / 60).toFixed(1)}h
{' • '}Los breaks de 30min se agregan automáticamente cada 8hrs
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="text-center py-8">Cargando horario...</div>
) : (
<div className="space-y-4">
{DAYS_OF_WEEK.map((day) => {
const date = new Date()
const currentDayOfWeek = date.getDay()
const targetDayOfWeek = DAYS_OF_WEEK.findIndex(d => d.key === day.key)
const daysUntil = (targetDayOfWeek - currentDayOfWeek + 7) % 7
date.setDate(date.getDate() + daysUntil)
const dateStr = date.toISOString().split('T')[0]
const daySchedules = getScheduleForDate(dateStr)
const dayBreaks = getBreaksForDate(dateStr)
const totalMinutes = daySchedules.reduce((total, s) => {
const start = parseInt(s.start_time.split(':')[0]) * 60 + parseInt(s.start_time.split(':')[1])
const end = parseInt(s.end_time.split(':')[0]) * 60 + parseInt(s.end_time.split(':')[1])
return total + (end - start)
}, 0)
const shouldHaveBreak = totalMinutes >= 480
return (
<div key={day.key} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-medium">{day.label}</span>
<span className="text-sm text-gray-500">{dateStr}</span>
</div>
<div className="flex items-center gap-2">
{shouldHaveBreak && dayBreaks.length === 0 && (
<Badge className="bg-yellow-100 text-yellow-800">
<Coffee className="w-3 h-3 mr-1" />
Break pendiente
</Badge>
)}
{dayBreaks.length > 0 && (
<Badge className="bg-green-100 text-green-800">
<Coffee className="w-3 h-3 mr-1" />
Break incluido
</Badge>
)}
<Badge variant={daySchedules.length > 0 ? 'default' : 'secondary'}>
{(totalMinutes / 60).toFixed(1)}h
</Badge>
</div>
</div>
{daySchedules.length > 0 ? (
<div className="space-y-2 ml-4">
{daySchedules.map((s) => (
<div key={s.id} className="flex items-center justify-between text-sm">
<span>{s.start_time} - {s.end_time}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(s.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
{dayBreaks.map((b) => (
<div key={b.id} className="flex items-center justify-between text-sm text-gray-500 ml-4 border-l-2 border-yellow-300 pl-2">
<span>{b.start_time} - {b.end_time} (Break)</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(b.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400 ml-4">Sin horario programado</p>
)}
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Agregar Día de Trabajo</DialogTitle>
<DialogDescription>
Define el horario de trabajo para este día
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="date" className="text-right">
Fecha
</Label>
<Input
id="date"
type="date"
value={formData.date}
onChange={(e) => setFormData({...formData, date: e.target.value})}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="start_time" className="text-right">
Inicio
</Label>
<Select
value={formData.start_time}
onValueChange={(value) => setFormData({...formData, start_time: value})}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_SLOTS.map((time) => (
<SelectItem key={time} value={time}>{time}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="end_time" className="text-right">
Fin
</Label>
<Select
value={formData.end_time}
onValueChange={(value) => setFormData({...formData, end_time: value})}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_SLOTS.map((time) => (
<SelectItem key={time} value={time}>{time}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="reason" className="text-right">
Notas
</Label>
<Input
id="reason"
value={formData.reason}
onChange={(e) => setFormData({...formData, reason: e.target.value})}
className="col-span-3"
placeholder="Opcional"
/>
</div>
</div>
<DialogFooter>
<Button type="submit">Guardar Horario</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -18,7 +18,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Avatar } from '@/components/ui/avatar'
import { Plus, Edit, Trash2, Phone, MapPin, Clock, Users } from 'lucide-react'
import { Checkbox } from '@/components/ui/checkbox'
import { Plus, Edit, Trash2, Phone, MapPin, Clock, Users, Scissors, X } from 'lucide-react'
import { useAuth } from '@/lib/auth/context'
interface StaffMember {
@@ -39,6 +40,16 @@ interface StaffMember {
schedule?: any[]
}
interface Service {
id: string
name: string
category: string
duration_minutes: number
base_price: number
isAssigned?: boolean
proficiency?: number
}
interface Location {
id: string
name: string
@@ -60,6 +71,10 @@ export default function StaffManagement() {
const [loading, setLoading] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null)
const [servicesDialogOpen, setServicesDialogOpen] = useState(false)
const [selectedStaffForServices, setSelectedStaffForServices] = useState<StaffMember | null>(null)
const [services, setServices] = useState<Service[]>([])
const [loadingServices, setLoadingServices] = useState(false)
const [formData, setFormData] = useState({
location_id: '',
role: '',
@@ -72,6 +87,63 @@ export default function StaffManagement() {
fetchLocations()
}, [])
const fetchServices = async (staffId: string) => {
setLoadingServices(true)
try {
const response = await fetch(`/api/aperture/staff/${staffId}/services`)
const data = await response.json()
if (data.success) {
setServices(data.availableServices || [])
}
} catch (error) {
console.error('Error fetching services:', error)
} finally {
setLoadingServices(false)
}
}
const openServicesDialog = async (member: StaffMember) => {
setSelectedStaffForServices(member)
await fetchServices(member.id)
setServicesDialogOpen(true)
}
const toggleServiceAssignment = async (serviceId: string, isCurrentlyAssigned: boolean) => {
if (!selectedStaffForServices) return
try {
if (isCurrentlyAssigned) {
await fetch(`/api/aperture/staff/${selectedStaffForServices.id}/services?service_id=${serviceId}`, {
method: 'DELETE'
})
} else {
await fetch(`/api/aperture/staff/${selectedStaffForServices.id}/services`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service_id: serviceId })
})
}
await fetchServices(selectedStaffForServices.id)
} catch (error) {
console.error('Error toggling service:', error)
}
}
const updateProficiency = async (serviceId: string, level: number) => {
if (!selectedStaffForServices) return
try {
await fetch(`/api/aperture/staff/${selectedStaffForServices.id}/services`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service_id: serviceId, proficiency_level: level })
})
await fetchServices(selectedStaffForServices.id)
} catch (error) {
console.error('Error updating proficiency:', error)
}
}
const fetchStaff = async () => {
setLoading(true)
try {
@@ -265,6 +337,16 @@ export default function StaffManagement() {
</TableCell>
<TableCell className="text-right">
<div className="flex items-center gap-2 justify-end">
{member.role === 'artist' && (
<Button
variant="outline"
size="sm"
onClick={() => openServicesDialog(member)}
title="Gestionar servicios"
>
<Scissors className="w-4 h-4" />
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -368,6 +450,72 @@ export default function StaffManagement() {
</form>
</DialogContent>
</Dialog>
<Dialog open={servicesDialogOpen} onOpenChange={setServicesDialogOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Scissors className="w-5 h-5" />
Servicios de {selectedStaffForServices?.display_name}
</DialogTitle>
<DialogDescription>
Selecciona los servicios que este artista puede realizar y su nivel de proficiency
</DialogDescription>
</DialogHeader>
{loadingServices ? (
<div className="text-center py-8">Cargando servicios...</div>
) : (
<div className="space-y-4">
{services.length === 0 ? (
<div className="text-center py-4 text-gray-500">No hay servicios disponibles</div>
) : (
services.map((service) => (
<div key={service.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<Checkbox
checked={service.isAssigned}
onCheckedChange={() => toggleServiceAssignment(service.id, service.isAssigned || false)}
/>
<div>
<p className="font-medium">{service.name}</p>
<p className="text-sm text-gray-500">
{service.category} {service.duration_minutes} min ${service.base_price}
</p>
</div>
</div>
{service.isAssigned && (
<div className="flex items-center gap-2">
<Label className="text-xs">Nivel:</Label>
<Select
value={String(service.proficiency || 3)}
onValueChange={(value) => updateProficiency(service.id, parseInt(value))}
>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 Principiante</SelectItem>
<SelectItem value="2">2 Intermedio</SelectItem>
<SelectItem value="3">3 Competente</SelectItem>
<SelectItem value="4">4 Profesional</SelectItem>
<SelectItem value="5">5 Experto</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
))
)}
</div>
)}
<DialogFooter>
<Button onClick={() => setServicesDialogOpen(false)}>
<X className="w-4 h-4 mr-2" />
Cerrar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

34
dev.log
View File

@@ -1,34 +0,0 @@
> anchoros@0.1.0 dev
> next dev -p 2311
▲ Next.js 14.0.4
- Local: http://localhost:2311
- Environments: .env.local
✓ Ready in 2.1s
○ Compiling /middleware ...
✓ Compiled /middleware in 1308ms (102 modules)
○ Compiling /aperture/login ...
✓ Compiled /aperture/login in 8s (520 modules)
○ Compiling /not-found ...
<w> [webpack.cache.PackFileCacheStrategy] Serializing big strings (102kiB) impacts deserialization performance (consider using Buffer instead and decode when needed)
<w> [webpack.cache.PackFileCacheStrategy] Serializing big strings (140kiB) impacts deserialization performance (consider using Buffer instead and decode when needed)
✓ Compiled /not-found in 6.6s (502 modules)
Reload env: .env
✓ Compiled in 1282ms (599 modules)
Reload env: .env
✓ Compiled in 238ms (599 modules)
○ Compiling /api/aperture/dashboard ...
✓ Compiled /api/aperture/dashboard in 1187ms (309 modules)
Aperture dashboard GET error: {
code: 'PGRST200',
details: "Searched for a foreign key relationship between 'bookings' and 'customer' in the schema 'public', but no matches were found.",
hint: "Perhaps you meant 'customers' instead of 'customer'.",
message: "Could not find a relationship between 'bookings' and 'customer' in the schema cache"
}
✓ Compiled in 1251ms (497 modules)
⚠ Fast Refresh had to perform a full reload. Read more: https://nextjs.org/docs/messages/fast-refresh-reload
○ Compiling /not-found ...
✓ Compiled /not-found in 1490ms (502 modules)
[?25h

49
lib/calendar-utils.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Calendar utilities for drag & drop operations
* Handles staff service validation, conflict checking, and booking rescheduling
*/
export const checkStaffCanPerformService = async (staffId: string, serviceId: string): Promise<boolean> => {
try {
const response = await fetch(`/api/aperture/staff/${staffId}/services`);
const data = await response.json();
return data.success && data.services.some((s: any) => s.services?.id === serviceId);
} catch (error) {
console.error('Error checking staff services:', error);
return false;
}
};
export const checkForConflicts = async (bookingId: string, staffId: string, startTime: string, duration: number): Promise<boolean> => {
try {
const endTime = new Date(new Date(startTime).getTime() + duration * 60 * 1000).toISOString();
// Check staff availability
const response = await fetch('/api/aperture/staff-unavailable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ staff_id: staffId, start_time: startTime, end_time: endTime, exclude_booking_id: bookingId })
});
const data = await response.json();
return !data.available; // If not available, there's a conflict
} catch (error) {
console.error('Error checking conflicts:', error);
return true; // Assume conflict on error
}
};
export const rescheduleBooking = async (bookingId: string, updates: any) => {
try {
const response = await fetch(`/api/aperture/bookings/${bookingId}/reschedule`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
return await response.json();
} catch (error) {
console.error('Error rescheduling booking:', error);
return { success: false, error: 'Network error' };
}
};

View File

@@ -1,7 +1,29 @@
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY!)
/**
* @description Email service integration using Resend API for transactional emails
* @audit BUSINESS RULE: Sends HTML-formatted emails with PDF receipt attachments
* @audit SECURITY: Requires RESEND_API_KEY environment variable for authentication
* @audit PERFORMANCE: Uses Resend SDK for reliable email delivery
* @audit AUDIT: Email send results logged for delivery tracking
*/
/** Resend client instance configured with API key */
const resendClient = new Resend(process.env.RESEND_API_KEY!)
/**
* @description Interface defining data required for receipt email
* @property {string} to - Recipient email address
* @property {string} customerName - Customer's first name for personalization
* @property {string} bookingId - UUID of the booking for receipt generation
* @property {string} serviceName - Name of the booked service
* @property {string} date - Formatted date of the appointment
* @property {string} time - Formatted time of the appointment
* @property {string} location - Name and address of the salon location
* @property {string} staffName - Assigned staff member name
* @property {number} price - Total price of the service in MXN
* @property {string} pdfUrl - URL path to the generated PDF receipt
*/
interface ReceiptEmailData {
to: string
customerName: string
@@ -15,7 +37,16 @@ interface ReceiptEmailData {
pdfUrl: string
}
/** @description Send receipt email to customer */
/**
* @description Sends a receipt confirmation email with PDF attachment to the customer
* @param {ReceiptEmailData} data - Email data including customer details and booking information
* @returns {Promise<{ success: boolean; data?: any; error?: any }>} - Result of email send operation
* @example sendReceiptEmail({ to: 'customer@email.com', customerName: 'Ana', bookingId: '...', serviceName: 'Manicure', date: '2026-01-21', time: '10:00', location: 'ANCHOR:23 Saltillo', staffName: 'Maria', price: 1500, pdfUrl: '/receipts/...' })
* @audit BUSINESS RULE: Sends branded HTML email with ANCHOR:23 styling and Spanish content
* @audit Validate: Attaches PDF receipt with booking ID in filename
* @audit PERFORMANCE: Single API call to Resend with HTML content and attachment
* @audit AUDIT: Email sending logged for customer communication tracking
*/
export async function sendReceiptEmail(data: ReceiptEmailData) {
try {
const emailHtml = `
@@ -75,7 +106,7 @@ export async function sendReceiptEmail(data: ReceiptEmailData) {
</html>
`
const { data: result, error } = await resend.emails.send({
const { data: result, error } = await resendClient.emails.send({
from: 'ANCHOR:23 <noreply@anchor23.mx>',
to: data.to,
subject: 'Confirmación de Reserva - ANCHOR:23',
@@ -99,4 +130,4 @@ export async function sendReceiptEmail(data: ReceiptEmailData) {
console.error('Email service error:', error)
return { success: false, error }
}
}
}

View File

@@ -2,7 +2,13 @@ import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
/**
* cn function that merges class names using clsx and tailwind-merge.
* @description Utility function that merges and deduplicates CSS class names using clsx and tailwind-merge
* @param {ClassValue[]} inputs - Array of class name values (strings, objects, arrays, or falsy values)
* @returns {string} - Merged CSS class string with Tailwind class conflicts resolved
* @example cn('px-4 py-2', { 'bg-blue-500': true }, ['text-white', 'font-bold'])
* @audit BUSINESS RULE: Resolves Tailwind CSS class conflicts by letting later classes override earlier ones
* @audit PERFORMANCE: Optimized for frequent use in component className props
* @audit Validate: Handles all clsx input types (strings, objects, arrays, nested objects)
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))

View File

@@ -1,13 +1,37 @@
/**
* @description Business hours utilities for managing location operating schedules
* @audit BUSINESS RULE: Business hours stored in JSONB format with day keys (sunday-saturday)
* @audit PERFORMANCE: All functions use O(1) lookups and O(n) iteration (max 7 days)
*/
import type { BusinessHours, DayHours } from '@/lib/db/types'
/** Array of day names in lowercase for consistent key access */
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const
/** Type representing valid day of week values */
type DayOfWeek = typeof DAYS[number]
/**
* @description Converts a Date object to its corresponding day of week string
* @param {Date} date - The date to extract day of week from
* @returns {DayOfWeek} - Lowercase day name (e.g., 'monday', 'tuesday')
* @example getDayOfWeek(new Date('2026-01-21')) // returns 'wednesday'
* @audit PERFORMANCE: Uses native getDay() method for O(1) conversion
*/
export function getDayOfWeek(date: Date): DayOfWeek {
return DAYS[date.getDay()]
}
export function isOpenNow(businessHours: BusinessHours, date = new Date): boolean {
/**
* @description Checks if the business is currently open based on business hours configuration
* @param {BusinessHours} businessHours - JSON object with day-by-day operating hours
* @param {Date} date - Optional date to check (defaults to current time)
* @returns {boolean} - True if business is open, false if closed
* @example isOpenNow({ monday: { open: '10:00', close: '19:00', is_closed: false } }, new Date())
* @audit BUSINESS RULE: Compares current time against open/close times in HH:MM format
* @audit Validate: Returns false immediately if day is marked as is_closed
*/
const day = getDayOfWeek(date)
const hours = businessHours[day]
@@ -29,6 +53,15 @@ export function isOpenNow(businessHours: BusinessHours, date = new Date): boolea
}
export function getNextOpenTime(businessHours: BusinessHours, from = new Date): Date | null {
/**
* @description Finds the next opening time within the next 7 days
* @param {BusinessHours} businessHours - JSON object with day-by-day operating hours
* @param {Date} from - Reference date to search from (defaults to current time)
* @returns {Date | null} - Next opening DateTime or null if no opening found within 7 days
* @example getNextOpenTime({ monday: { open: '10:00', close: '19:00' }, sunday: { is_closed: true } })
* @audit BUSINESS RULE: Scans up to 7 days ahead to find next available opening
* @audit PERFORMANCE: O(7) iteration worst case, exits early when found
*/
const checkDate = new Date(from)
for (let i = 0; i < 7; i++) {
@@ -56,6 +89,15 @@ export function getNextOpenTime(businessHours: BusinessHours, from = new Date):
}
export function isTimeWithinHours(time: string, dayHours: DayHours): boolean {
/**
* @description Validates if a given time falls within operating hours for a specific day
* @param {string} time - Time in HH:MM format (e.g., '14:30')
* @param {DayHours} dayHours - Operating hours for a single day with open, close, and is_closed
* @returns {boolean} - True if time is within operating hours, false otherwise
* @example isTimeWithinHours('14:30', { open: '10:00', close: '19:00', is_closed: false }) // true
* @audit BUSINESS RULE: Converts times to minutes for accurate comparison
* @audit Validate: Returns false immediately if dayHours.is_closed is true
*/
if (dayHours.is_closed) {
return false
}
@@ -72,6 +114,13 @@ export function isTimeWithinHours(time: string, dayHours: DayHours): boolean {
}
export function getBusinessHoursString(dayHours: DayHours): string {
/**
* @description Formats day hours for display in UI
* @param {DayHours} dayHours - Operating hours for a single day
* @returns {string} - Formatted string (e.g., '10:00 - 19:00' or 'Cerrado')
* @example getBusinessHoursString({ open: '10:00', close: '19:00', is_closed: false }) // '10:00 - 19:00'
* @audit BUSINESS RULE: Returns 'Cerrado' (Spanish for closed) when is_closed is true
*/
if (dayHours.is_closed) {
return 'Cerrado'
}
@@ -79,6 +128,13 @@ export function getBusinessHoursString(dayHours: DayHours): string {
}
export function getTodayHours(businessHours: BusinessHours): string {
/**
* @description Gets formatted operating hours for the current day
* @param {BusinessHours} businessHours - JSON object with day-by-day operating hours
* @returns {string} - Formatted hours string for today (e.g., '10:00 - 19:00' or 'Cerrado')
* @example getTodayHours(businessHoursConfig) // Returns hours for current day of week
* @audit PERFORMANCE: Single lookup using getDayOfWeek on current date
*/
const day = getDayOfWeek(new Date())
return getBusinessHoursString(businessHours[day])
}

View File

@@ -1,8 +1,22 @@
/**
* @description Webhook utility for sending HTTP POST notifications to external services
* @audit BUSINESS RULE: Sends payloads to multiple webhook endpoints for redundancy
* @audit SECURITY: Endpoints configured via environment constants (not exposed to client)
*/
/** Array of webhook endpoint URLs for sending notifications */
export const WEBHOOK_ENDPOINTS = [
'https://flows.soul23.cloud/webhook-test/4YZ7RPfo1GT',
'https://flows.soul23.cloud/webhook/4YZ7RPfo1GT'
]
/**
* @description Detects the current device type based on viewport width
* @returns {string} - Device type: 'mobile' (≤768px), 'desktop' (>768px), or 'unknown' (server-side)
* @example getDeviceType() // returns 'desktop' or 'mobile'
* @audit PERFORMANCE: Uses native window.matchMedia for client-side detection
* @audit Validate: Returns 'unknown' when running server-side (typeof window === 'undefined')
*/
export const getDeviceType = () => {
if (typeof window === 'undefined') {
return 'unknown'
@@ -11,6 +25,17 @@ export const getDeviceType = () => {
return window.matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop'
}
/**
* @description Sends a webhook payload to all configured endpoints with fallback redundancy
* @param {Record<string, string>} payload - Key-value data to send in webhook request body
* @returns {Promise<void>} - Resolves if at least one endpoint receives the payload successfully
* @example await sendWebhookPayload({ event: 'booking_created', bookingId: '...' })
* @audit BUSINESS RULE: Uses Promise.allSettled to attempt all endpoints and succeed if any succeed
* @audit SECURITY: Sends JSON content type with stringified payload
* @audit Validate: Throws error if ALL endpoints fail (no successful responses)
* @audit PERFORMANCE: Parallel execution to all endpoints for fast delivery
* @audit AUDIT: Webhook delivery attempts logged for debugging
*/
export const sendWebhookPayload = async (payload: Record<string, string>) => {
const results = await Promise.allSettled(
WEBHOOK_ENDPOINTS.map(async (endpoint) => {

698
ralphy.sh
View File

@@ -1,698 +0,0 @@
#!/usr/bin/env bash
# ============================================
# Ralphy - Autonomous AI Coding Loop
# Supports Claude Code, OpenCode, Codex, Cursor, Qwen-Code and Factory Droid
# Runs until PRD is complete
# ============================================
set -euo pipefail
# ============================================
# CONFIGURATION & DEFAULTS
# ============================================
VERSION="4.0.0"
# Ralphy config directory
RALPHY_DIR=".ralphy"
PROGRESS_FILE="$RALPHY_DIR/progress.txt"
CONFIG_FILE="$RALPHY_DIR/config.yaml"
SINGLE_TASK=""
INIT_MODE=false
SHOW_CONFIG=false
ADD_RULE=""
AUTO_COMMIT=true
# Runtime options
SKIP_TESTS=false
SKIP_LINT=false
AI_ENGINE="claude" # claude, opencode, cursor, codex, qwen, or droid
DRY_RUN=false
MAX_ITERATIONS=0 # 0 = unlimited
MAX_RETRIES=3
RETRY_DELAY=5
VERBOSE=false
# Git branch options
BRANCH_PER_TASK=false
CREATE_PR=false
BASE_BRANCH=""
PR_DRAFT=false
# Parallel execution
PARALLEL=false
MAX_PARALLEL=3
# PRD source options
PRD_SOURCE="markdown" # markdown, yaml, github
PRD_FILE="PRD.md"
GITHUB_REPO=""
GITHUB_LABEL=""
# Colors (detect if terminal supports colors)
if [[ -t 1 ]] && command -v tput &>/dev/null && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
RED=$(tput setaf 1)
GREEN=$(tput setaf 2)
YELLOW=$(tput setaf 3)
BLUE=$(tput setaf 4)
MAGENTA=$(tput setaf 5)
CYAN=$(tput setaf 6)
BOLD=$(tput bold)
DIM=$(tput dim)
RESET=$(tput sgr0)
else
RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" BOLD="" DIM="" RESET=""
fi
# Global state
ai_pid=""
monitor_pid=""
tmpfile=""
CODEX_LAST_MESSAGE_FILE=""
current_step="Thinking"
total_input_tokens=0
total_output_tokens=0
total_actual_cost="0" # OpenCode provides actual cost
total_duration_ms=0 # Cursor provides duration
iteration=0
retry_count=0
declare -a parallel_pids=()
declare -a task_branches=()
declare -a integration_branches=() # Track integration branches for cleanup on interrupt
WORKTREE_BASE="" # Base directory for parallel agent worktrees
ORIGINAL_DIR="" # Original working directory (for worktree operations)
ORIGINAL_BASE_BRANCH="" # Original base branch before integration branches
# ============================================
# UTILITY FUNCTIONS
# ============================================
log_info() {
echo "${BLUE}[INFO]${RESET} $*"
}
log_success() {
echo "${GREEN}[OK]${RESET} $*"
}
log_warn() {
echo "${YELLOW}[WARN]${RESET} $*"
}
log_error() {
echo "${RED}[ERROR]${RESET} $*" >&2
}
log_debug() {
if [[ "$VERBOSE" == true ]]; then
echo "${DIM}[DEBUG] $*${RESET}"
fi
}
# Slugify text for branch names
slugify() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g' | sed -E 's/^-|-$//g' | cut -c1-50
}
# ============================================
# BROWNFIELD MODE (.ralphy/ configuration)
# ============================================
# Initialize .ralphy/ directory with config files
init_ralphy_config() {
if [[ -d "$RALPHY_DIR" ]]; then
log_warn "$RALPHY_DIR already exists"
REPLY='N' # Default if read times out or fails
read -p "Overwrite config? [y/N] " -n 1 -r -t 30 2>/dev/null || true
echo
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 0
fi
mkdir -p "$RALPHY_DIR"
# Smart detection
local project_name=""
local lang=""
local framework=""
local test_cmd=""
local lint_cmd=""
local build_cmd=""
# Get project name from directory or package.json
project_name=$(basename "$PWD")
if [[ -f "package.json" ]]; then
# Get name from package.json if available
local pkg_name
pkg_name=$(jq -r '.name // ""' package.json 2>/dev/null)
[[ -n "$pkg_name" ]] && project_name="$pkg_name"
# Detect language
if [[ -f "tsconfig.json" ]]; then
lang="TypeScript"
else
lang="JavaScript"
fi
# Detect frameworks from dependencies (collect all matches)
local deps frameworks=()
deps=$(jq -r '(.dependencies // {}) + (.devDependencies // {}) | keys[]' package.json 2>/dev/null || true)
# Use grep for reliable exact matching
echo "$deps" | grep -qx "next" && frameworks+=("Next.js")
echo "$deps" | grep -qx "nuxt" && frameworks+=("Nuxt")
echo "$deps" | grep -qx "@remix-run/react" && frameworks+=("Remix")
echo "$deps" | grep -qx "svelte" && frameworks+=("Svelte")
echo "$deps" | grep -qE "@nestjs/" && frameworks+=("NestJS")
echo "$deps" | grep -qx "hono" && frameworks+=("Hono")
echo "$deps" | grep -qx "fastify" && frameworks+=("Fastify")
echo "$deps" | grep -qx "express" && frameworks+=("Express")
# Only add React/Vue if no meta-framework detected
if [[ ${#frameworks[@]} -eq 0 ]]; then
echo "$deps" | grep -qx "react" && frameworks+=("React")
echo "$deps" | grep -qx "vue" && frameworks+=("Vue")
fi
# Join frameworks with comma
framework=$(IFS=', '; echo "${frameworks[*]}")
# Detect commands from package.json scripts
local scripts
scripts=$(jq -r '.scripts // {}' package.json 2>/dev/null)
# Test command (prefer bun if lockfile exists)
if echo "$scripts" | jq -e '.test' >/dev/null 2>&1; then
test_cmd="npm test"
[[ -f "bun.lockb" ]] && test_cmd="bun test"
fi
# Lint command
if echo "$scripts" | jq -e '.lint' >/dev/null 2>&1; then
lint_cmd="npm run lint"
fi
# Build command
if echo "$scripts" | jq -e '.build' >/dev/null 2>&1; then
build_cmd="npm run build"
fi
elif [[ -f "pyproject.toml" ]] || [[ -f "requirements.txt" ]] || [[ -f "setup.py" ]]; then
lang="Python"
local py_frameworks=()
local py_deps=""
[[ -f "pyproject.toml" ]] && py_deps=$(cat pyproject.toml 2>/dev/null)
[[ -f "requirements.txt" ]] && py_deps+=$(cat requirements.txt 2>/dev/null)
echo "$py_deps" | grep -qi "fastapi" && py_frameworks+=("FastAPI")
echo "$py_deps" | grep -qi "django" && py_frameworks+=("Django")
echo "$py_deps" | grep -qi "flask" && py_frameworks+=("Flask")
framework=$(IFS=', '; echo "${py_frameworks[*]}")
test_cmd="pytest"
lint_cmd="ruff check ."
elif [[ -f "go.mod" ]]; then
lang="Go"
test_cmd="go test ./..."
lint_cmd="golangci-lint run"
elif [[ -f "Cargo.toml" ]]; then
lang="Rust"
test_cmd="cargo test"
lint_cmd="cargo clippy"
build_cmd="cargo build"
fi
# Show what we detected
echo ""
echo "${BOLD}Detected:${RESET}"
echo " Project: ${CYAN}$project_name${RESET}"
[[ -n "$lang" ]] && echo " Language: ${CYAN}$lang${RESET}"
[[ -n "$framework" ]] && echo " Framework: ${CYAN}$framework${RESET}"
[[ -n "$test_cmd" ]] && echo " Test: ${CYAN}$test_cmd${RESET}"
[[ -n "$lint_cmd" ]] && echo " Lint: ${CYAN}$lint_cmd${RESET}"
[[ -n "$build_cmd" ]] && echo " Build: ${CYAN}$build_cmd${RESET}"
echo ""
# Escape values for safe YAML (double quotes inside strings)
yaml_escape() { printf '%s' "$1" | sed 's/"/\\"/g'; }
# Create config.yaml with detected values
cat > "$CONFIG_FILE" << EOF
# Ralphy Configuration
# https://github.com/michaelshimeles/ralphy
# Project info (auto-detected, edit if needed)
project:
name: "$(yaml_escape "$project_name")"
language: "$(yaml_escape "${lang:-Unknown}")"
framework: "$(yaml_escape "${framework:-}")"
description: "" # Add a brief description
# Commands (auto-detected from package.json/pyproject.toml)
commands:
test: "$(yaml_escape "${test_cmd:-}")"
lint: "$(yaml_escape "${lint_cmd:-}")"
build: "$(yaml_escape "${build_cmd:-}")"
# Rules - instructions the AI MUST follow
# These are injected into every prompt
rules: []
# Examples:
# - "Always use TypeScript strict mode"
# - "Follow the error handling pattern in src/utils/errors.ts"
# - "All API endpoints must have input validation with Zod"
# - "Use server actions instead of API routes in Next.js"
# Boundaries - files/folders the AI should not modify
boundaries:
never_touch: []
# Examples:
# - "src/legacy/**"
# - "migrations/**"
# - "*.lock"
EOF
# Create progress.txt
echo "# Ralphy Progress Log" > "$PROGRESS_FILE"
echo "" >> "$PROGRESS_FILE"
log_success "Created $RALPHY_DIR/"
echo ""
echo " ${CYAN}$CONFIG_FILE${RESET} - Your rules and preferences"
echo " ${CYAN}$PROGRESS_FILE${RESET} - Progress log (auto-updated)"
echo ""
echo "${BOLD}Next steps:${RESET}"
echo " 1. Add rules: ${CYAN}ralphy --add-rule \"your rule here\"${RESET}"
echo " 2. Or edit: ${CYAN}$CONFIG_FILE${RESET}"
echo " 3. Run: ${CYAN}ralphy \"your task\"${RESET} or ${CYAN}ralphy${RESET} (with PRD.md)"
}
# Load rules from config.yaml
load_ralphy_rules() {
[[ ! -f "$CONFIG_FILE" ]] && return
if command -v yq &>/dev/null; then
yq -r '.rules // [] | .[]' "$CONFIG_FILE" 2>/dev/null || true
fi
}
# Load boundaries from config.yaml
load_ralphy_boundaries() {
local boundary_type="$1" # never_touch or always_test
[[ ! -f "$CONFIG_FILE" ]] && return
if command -v yq &>/dev/null; then
yq -r ".boundaries.$boundary_type // [] | .[]" "$CONFIG_FILE" 2>/dev/null || true
fi
}
# Show current config
show_ralphy_config() {
if [[ ! -f "$CONFIG_FILE" ]]; then
log_warn "No config found. Run 'ralphy --init' first."
exit 1
fi
echo ""
echo "${BOLD}Ralphy Configuration${RESET} ($CONFIG_FILE)"
echo ""
if command -v yq &>/dev/null; then
# Project info
local name lang framework desc
name=$(yq -r '.project.name // "Unknown"' "$CONFIG_FILE" 2>/dev/null)
lang=$(yq -r '.project.language // "Unknown"' "$CONFIG_FILE" 2>/dev/null)
framework=$(yq -r '.project.framework // ""' "$CONFIG_FILE" 2>/dev/null)
desc=$(yq -r '.project.description // ""' "$CONFIG_FILE" 2>/dev/null)
echo "${BOLD}Project:${RESET}"
echo " Name: $name"
echo " Language: $lang"
[[ -n "$framework" ]] && echo " Framework: $framework"
[[ -n "$desc" ]] && echo " About: $desc"
echo ""
# Commands
local test_cmd lint_cmd build_cmd
test_cmd=$(yq -r '.commands.test // ""' "$CONFIG_FILE" 2>/dev/null)
lint_cmd=$(yq -r '.commands.lint // ""' "$CONFIG_FILE" 2>/dev/null)
build_cmd=$(yq -r '.commands.build // ""' "$CONFIG_FILE" 2>/dev/null)
echo "${BOLD}Commands:${RESET}"
[[ -n "$test_cmd" ]] && echo " Test: $test_cmd" || echo " Test: ${DIM}(not set)${RESET}"
[[ -n "$lint_cmd" ]] && echo " Lint: $lint_cmd" || echo " Lint: ${DIM}(not set)${RESET}"
[[ -n "$build_cmd" ]] && echo " Build: $build_cmd" || echo " Build: ${DIM}(not set)${RESET}"
echo ""
# Rules
echo "${BOLD}Rules:${RESET}"
local rules
rules=$(yq -r '.rules // [] | .[]' "$CONFIG_FILE" 2>/dev/null)
if [[ -n "$rules" ]]; then
echo "$rules" | while read -r rule; do
echo "$rule"
done
else
echo " ${DIM}(none - add with: ralphy --add-rule \"...\")${RESET}"
fi
echo ""
# Boundaries
local never_touch
never_touch=$(yq -r '.boundaries.never_touch // [] | .[]' "$CONFIG_FILE" 2>/dev/null)
if [[ -n "$never_touch" ]]; then
echo "${BOLD}Never Touch:${RESET}"
echo "$never_touch" | while read -r path; do
echo "$path"
done
echo ""
fi
else
# Fallback: just show the file
cat "$CONFIG_FILE"
fi
}
# Add a rule to config.yaml
add_ralphy_rule() {
local rule="$1"
if [[ ! -f "$CONFIG_FILE" ]]; then
log_error "No config found. Run 'ralphy --init' first."
exit 1
fi
if ! command -v yq &>/dev/null; then
log_error "yq is required to add rules. Install from https://github.com/mikefarah/yq"
log_info "Or manually edit $CONFIG_FILE"
exit 1
fi
# Add rule to the rules array (use env var to avoid YAML injection)
RULE="$rule" yq -i '.rules += [env(RULE)]' "$CONFIG_FILE"
log_success "Added rule: $rule"
}
# Load test command from config
load_test_command() {
[[ ! -f "$CONFIG_FILE" ]] && echo "" && return
if command -v yq &>/dev/null; then
yq -r '.commands.test // ""' "$CONFIG_FILE" 2>/dev/null || echo ""
else
echo ""
fi
}
# Load project context from config.yaml
load_project_context() {
[[ ! -f "$CONFIG_FILE" ]] && return
if command -v yq &>/dev/null; then
local name lang framework desc
name=$(yq -r '.project.name // ""' "$CONFIG_FILE" 2>/dev/null)
lang=$(yq -r '.project.language // ""' "$CONFIG_FILE" 2>/dev/null)
framework=$(yq -r '.project.framework // ""' "$CONFIG_FILE" 2>/dev/null)
desc=$(yq -r '.project.description // ""' "$CONFIG_FILE" 2>/dev/null)
local context=""
[[ -n "$name" ]] && context+="Project: $name\n"
[[ -n "$lang" ]] && context+="Language: $lang\n"
[[ -n "$framework" ]] && context+="Framework: $framework\n"
[[ -n "$desc" ]] && context+="Description: $desc\n"
echo -e "$context"
fi
}
# Log task to progress file
log_task_history() {
local task="$1"
local status="$2" # completed, failed
[[ ! -f "$PROGRESS_FILE" ]] && return
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M')
local icon="✓"
[[ "$status" == "failed" ]] && icon="✗"
echo "- [$icon] $timestamp - $task" >> "$PROGRESS_FILE"
}
# Build prompt with brownfield context
build_brownfield_prompt() {
local task="$1"
local prompt=""
# Add project context if available
local context
context=$(load_project_context)
if [[ -n "$context" ]]; then
prompt+="## Project Context
$context
"
fi
# Add rules if available
local rules
rules=$(load_ralphy_rules)
if [[ -n "$rules" ]]; then
prompt+="## Rules (you MUST follow these)
$rules
"
fi
# Add boundaries
local never_touch
never_touch=$(load_ralphy_boundaries "never_touch")
if [[ -n "$never_touch" ]]; then
prompt+="## Boundaries
Do NOT modify these files/directories:
$never_touch
"
fi
# Add the task
prompt+="## Task
$task
## Instructions
1. Implement the task described above
2. Write tests if appropriate
3. Ensure the code works correctly"
# Add commit instruction only if auto-commit is enabled
if [[ "$AUTO_COMMIT" == "true" ]]; then
prompt+="
4. Commit your changes with a descriptive message"
fi
prompt+="
Keep changes focused and minimal. Do not refactor unrelated code."
echo "$prompt"
}
# Run a single brownfield task
run_brownfield_task() {
local task="$1"
echo ""
echo "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo "${BOLD}Task:${RESET} $task"
echo "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo ""
local prompt
prompt=$(build_brownfield_prompt "$task")
# Create temp file for output
local output_file
output_file=$(mktemp)
log_info "Running with $AI_ENGINE..."
# Run the AI engine (tee to show output while saving for parsing)
case "$AI_ENGINE" in
claude)
claude --dangerously-skip-permissions \
-p "$prompt" 2>&1 | tee "$output_file"
;;
opencode)
opencode --output-format stream-json \
--approval-mode full-auto \
"$prompt" 2>&1 | tee "$output_file"
;;
cursor)
agent --dangerously-skip-permissions \
-p "$prompt" 2>&1 | tee "$output_file"
;;
qwen)
qwen --output-format stream-json \
--approval-mode yolo \
-p "$prompt" 2>&1 | tee "$output_file"
;;
droid)
droid exec --output-format stream-json \
--auto medium \
"$prompt" 2>&1 | tee "$output_file"
;;
codex)
codex exec --full-auto \
--json \
"$prompt" 2>&1 | tee "$output_file"
;;
esac
local exit_code=$?
# Log to history
if [[ $exit_code -eq 0 ]]; then
log_task_history "$task" "completed"
log_success "Task completed"
else
log_task_history "$task" "failed"
log_error "Task failed"
fi
rm -f "$output_file"
return $exit_code
}
# ============================================
# HELP & VERSION
# ============================================
show_help() {
cat << EOF
${BOLD}Ralphy${RESET} - Autonomous AI Coding Loop (v${VERSION})
${BOLD}USAGE:${RESET}
./ralphy.sh [options] # PRD mode (requires PRD.md)
./ralphy.sh "task description" # Single task mode (brownfield)
./ralphy.sh --init # Initialize .ralphy/ config
${BOLD}CONFIG & SETUP:${RESET}
--init Initialize .ralphy/ with smart defaults
--config Show current configuration
--add-rule "..." Add a rule to config (e.g., "Always use Zod")
${BOLD}SINGLE TASK MODE:${RESET}
"task description" Run a single task without PRD (quotes required)
--no-commit Don't auto-commit after task completion
${BOLD}AI ENGINE OPTIONS:${RESET}
--claude Use Claude Code (default)
--opencode Use OpenCode
--cursor Use Cursor agent
--codex Use Codex CLI
--qwen Use Qwen-Code
--droid Use Factory Droid
${BOLD}WORKFLOW OPTIONS:${RESET}
--no-tests Skip writing and running tests
--no-lint Skip linting
--fast Skip both tests and linting
${BOLD}EXECUTION OPTIONS:${RESET}
--max-iterations N Stop after N iterations (0 = unlimited)
--max-retries N Max retries per task on failure (default: 3)
--retry-delay N Seconds between retries (default: 5)
--dry-run Show what would be done without executing
${BOLD}PARALLEL EXECUTION:${RESET}
--parallel Run independent tasks in parallel
--max-parallel N Max concurrent tasks (default: 3)
${BOLD}GIT BRANCH OPTIONS:${RESET}
--branch-per-task Create a new git branch for each task
--base-branch NAME Base branch to create task branches from (default: current)
--create-pr Create a pull request after each task (requires gh CLI)
--draft-pr Create PRs as drafts
${BOLD}PRD SOURCE OPTIONS:${RESET}
--prd FILE PRD file path (default: PRD.md)
--yaml FILE Use YAML task file instead of markdown
--github REPO Fetch tasks from GitHub issues (e.g., owner/repo)
--github-label TAG Filter GitHub issues by label
${BOLD}OTHER OPTIONS:${RESET}
-v, --verbose Show debug output
-h, --help Show this help
--version Show version number
${BOLD}EXAMPLES:${RESET}
# Brownfield mode (single tasks in existing projects)
./ralphy.sh --init # Initialize config
./ralphy.sh "add dark mode toggle" # Run single task
./ralphy.sh "fix the login bug" --cursor # Single task with Cursor
# PRD mode (task lists)
./ralphy.sh # Run with Claude Code
./ralphy.sh --codex # Run with Codex CLI
./ralphy.sh --branch-per-task --create-pr # Feature branch workflow
./ralphy.sh --parallel --max-parallel 4 # Run 4 tasks concurrently
./ralphy.sh --yaml tasks.yaml # Use YAML task file
./ralphy.sh --github owner/repo # Fetch from GitHub issues
${BOLD}PRD FORMATS:${RESET}
Markdown (PRD.md):
- [ ] Task description
YAML (tasks.yaml):
tasks:
- title: Task description
completed: false
parallel_group: 1 # Optional: tasks with same group run in parallel
GitHub Issues:
Uses open issues from the specified repository
EOF
}
show_version() {
echo "Ralphy v${VERSION}"
}
# ============================================
# ARGUMENT PARSING
# ============================================
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
--no-tests|--skip-tests)
SKIP_TESTS=true
shift
;;
--no-lint|--skip-lint)
SKIP_LINT=true
shift
;;
--fast)
SKIP_TESTS=true
SKIP_LINT=true
shift
;;
--opencode)
AI_ENGINE="opencode"
shift
;;
--claude)
AI_ENGINE="claude"
shift
;;
--cursor|--agent)
AI_ENGINE="cursor"
shift
;;
--codex)
AI_ENGINE="codex"
shift
;;
--qwen)

View File

View File

@@ -0,0 +1,40 @@
-- ============================================
-- ADD ANCHOR 23 MENU STRUCTURE
-- Date: 20260120
-- Description: Add columns to support complex service structure from Anchor 23 menu
-- ============================================
-- Add new columns for complex service structure
ALTER TABLE services ADD COLUMN IF NOT EXISTS subtitle VARCHAR(200);
ALTER TABLE services ADD COLUMN IF NOT EXISTS price_type VARCHAR(20) DEFAULT 'fixed';
ALTER TABLE services ADD COLUMN IF NOT EXISTS duration_min INTEGER;
ALTER TABLE services ADD COLUMN IF NOT EXISTS duration_max INTEGER;
ALTER TABLE services ADD COLUMN IF NOT EXISTS requires_prerequisite BOOLEAN DEFAULT false;
ALTER TABLE services ADD COLUMN IF NOT EXISTS prerequisite_details JSONB;
ALTER TABLE services ADD COLUMN IF NOT EXISTS membership_benefits JSONB;
-- Update existing duration_minutes to duration_max for backward compatibility
-- This ensures existing services still work while new services can use ranges
UPDATE services SET duration_max = duration_minutes WHERE duration_max IS NULL AND duration_minutes IS NOT NULL;
-- Add check constraints for new fields
ALTER TABLE services ADD CONSTRAINT IF NOT EXISTS check_price_type
CHECK (price_type IN ('fixed', 'starting_at'));
ALTER TABLE services ADD CONSTRAINT IF NOT EXISTS check_duration_range
CHECK (duration_min IS NULL OR duration_max IS NULL OR duration_min <= duration_max);
ALTER TABLE services ADD CONSTRAINT IF NOT EXISTS check_duration_not_null
CHECK (
(duration_min IS NOT NULL AND duration_max IS NOT NULL) OR
(duration_min IS NULL AND duration_max IS NOT NULL)
);
-- Add comments for documentation
COMMENT ON COLUMN services.subtitle IS 'Optional subtitle displayed under service name';
COMMENT ON COLUMN services.price_type IS 'fixed or starting_at pricing type';
COMMENT ON COLUMN services.duration_min IS 'Minimum duration in minutes for ranged services';
COMMENT ON COLUMN services.duration_max IS 'Maximum duration in minutes for ranged services';
COMMENT ON COLUMN services.requires_prerequisite IS 'Whether service requires prerequisite service';
COMMENT ON COLUMN services.prerequisite_details IS 'JSON details about prerequisite requirements';
COMMENT ON COLUMN services.membership_benefits IS 'JSON details about member-specific benefits';

View File

@@ -0,0 +1,85 @@
-- ============================================
-- FIX: Correct function calls in check_staff_availability
-- Date: 2026-01-21
-- Description: Fix parameter issues in check_staff_availability function calls
-- ============================================
-- Drop and recreate check_staff_availability with correct function calls
DROP FUNCTION IF EXISTS check_staff_availability(UUID, TIMESTAMPTZ, TIMESTAMPTZ, UUID) CASCADE;
CREATE OR REPLACE FUNCTION check_staff_availability(
p_staff_id UUID,
p_start_time_utc TIMESTAMPTZ,
p_end_time_utc TIMESTAMPTZ,
p_exclude_booking_id UUID DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_staff RECORD;
v_location_timezone TEXT;
v_has_work_conflict BOOLEAN := false;
v_has_booking_conflict BOOLEAN := false;
v_has_calendar_conflict BOOLEAN := false;
v_has_block_conflict BOOLEAN := false;
BEGIN
-- 1. Check if staff exists and is active
SELECT s.*, l.timezone INTO v_staff, v_location_timezone
FROM staff s
JOIN locations l ON s.location_id = l.id
WHERE s.id = p_staff_id;
IF NOT FOUND OR NOT v_staff.is_active OR NOT v_staff.is_available_for_booking THEN
RETURN false;
END IF;
-- 2. Check work hours and days (with correct parameters)
v_has_work_conflict := NOT check_staff_work_hours(p_staff_id, p_start_time_utc, p_end_time_utc, v_location_timezone);
IF v_has_work_conflict THEN
RETURN false;
END IF;
-- 3. Check existing bookings conflict
SELECT EXISTS (
SELECT 1 FROM bookings b
WHERE b.staff_id = p_staff_id
AND b.status != 'cancelled'
AND b.start_time_utc < p_end_time_utc
AND b.end_time_utc > p_start_time_utc
AND (p_exclude_booking_id IS NULL OR b.id != p_exclude_booking_id)
) INTO v_has_booking_conflict;
IF v_has_booking_conflict THEN
RETURN false;
END IF;
-- 4. Check manual blocks conflict
SELECT EXISTS (
SELECT 1 FROM staff_availability sa
WHERE sa.staff_id = p_staff_id
AND sa.date = p_start_time_utc::DATE
AND sa.is_available = false
AND (p_start_time_utc::TIME >= sa.start_time AND p_start_time_utc::TIME < sa.end_time
OR p_end_time_utc::TIME > sa.start_time AND p_end_time_utc::TIME <= sa.end_time
OR p_start_time_utc::TIME <= sa.start_time AND p_end_time_utc::TIME >= sa.end_time)
) INTO v_has_block_conflict;
IF v_has_block_conflict THEN
RETURN false;
END IF;
-- 5. Check Google Calendar blocking events conflict
v_has_calendar_conflict := NOT check_calendar_blocking(p_staff_id, p_start_time_utc, p_end_time_utc, p_exclude_booking_id);
IF v_has_calendar_conflict THEN
RETURN false;
END IF;
-- All checks passed - staff is available
RETURN true;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Grant execute permission
GRANT EXECUTE ON FUNCTION check_staff_availability TO authenticated, anon, service_role;
COMMENT ON FUNCTION check_staff_availability IS 'Enhanced availability check including work hours, bookings, manual blocks, and Google Calendar sync with corrected function calls';

View File

@@ -0,0 +1,75 @@
-- ============================================
-- STAFF SERVICES MANAGEMENT
-- Date: 2026-01-21
-- Description: Add staff_services table and proficiency system
-- ============================================
-- Create staff_services table
CREATE TABLE staff_services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
proficiency_level INTEGER CHECK (proficiency_level >= 1 AND proficiency_level <= 5) DEFAULT 3,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(staff_id, service_id)
);
-- Add indexes for performance
CREATE INDEX idx_staff_services_staff_id ON staff_services(staff_id);
CREATE INDEX idx_staff_services_service_id ON staff_services(service_id);
CREATE INDEX idx_staff_services_active ON staff_services(is_active);
-- Add RLS policies
ALTER TABLE staff_services ENABLE ROW LEVEL SECURITY;
-- Policy: Staff can view their own services
CREATE POLICY "Staff can view own services"
ON staff_services
FOR SELECT
USING (
auth.uid()::text = (
SELECT user_id::text FROM staff WHERE id = staff_id
)
);
-- Policy: Managers and admins can view all staff services
CREATE POLICY "Managers and admins can view all staff services"
ON staff_services
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM staff s
WHERE s.user_id::text = auth.uid()::text
AND s.role IN ('manager', 'admin')
)
);
-- Policy: Managers and admins can manage staff services
CREATE POLICY "Managers and admins can manage staff services"
ON staff_services
FOR ALL
USING (
EXISTS (
SELECT 1 FROM staff s
WHERE s.user_id::text = auth.uid()::text
AND s.role IN ('manager', 'admin')
)
);
-- Add audit columns to bookings for tracking auto-assignment and invitations
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS invitation_code_used TEXT;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS auto_assigned BOOLEAN DEFAULT false;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS assigned_by UUID REFERENCES staff(id);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_bookings_invitation_code ON bookings(invitation_code_used);
CREATE INDEX IF NOT EXISTS idx_bookings_auto_assigned ON bookings(auto_assigned);
-- Grant permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON staff_services TO authenticated;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO authenticated;
COMMENT ON TABLE staff_services IS 'Tracks which services each staff member can perform and their proficiency level';
COMMENT ON COLUMN staff_services.proficiency_level IS '1=Beginner, 2=Intermediate, 3=Competent, 4=Proficient, 5=Expert';

94
weak_points.md Normal file
View File

@@ -0,0 +1,94 @@
# Puntos Débiles y Oportunidades de Refactorización en AnchorOS
Este documento detalla los puntos débiles, áreas de mejora y oportunidades de refactorización identificadas en la base de código de AnchorOS. El objetivo es proporcionar una guía clara para futuras tareas de mantenimiento y mejora de la calidad del software.
## 1. Gestión de Dependencias
Se ha identificado que el proyecto tiene una gran cantidad de dependencias desactualizadas o faltantes, según el resultado del comando `npm outdated`.
### Riesgos Asociados
- **Vulnerabilidades de Seguridad:** Las versiones antiguas de los paquetes pueden contener vulnerabilidades conocidas que ya han sido corregidas en versiones más recientes.
- **Bugs y Problemas de Compatibilidad:** Las nuevas versiones de las dependencias suelen incluir correcciones de errores y mejoras de rendimiento. Mantener las dependencias desactualizadas puede provocar un comportamiento inesperado y problemas de compatibilidad.
- **Dificultad en el Mantenimiento:** Un gran número de dependencias desactualizadas dificulta la actualización del proyecto y la adopción de nuevas funcionalidades.
### Recomendación
- **Actualizar Dependencias:** Se recomienda actualizar todas las dependencias a sus últimas versiones estables.
- **Utilizar `npm install`:** Para asegurar que todas las dependencias declaradas en `package.json` estén correctamente instaladas.
- **Integrar Renovate o Dependabot:** Para automatizar el proceso de actualización de dependencias y mantener el proyecto al día.
## 2. Ausencia de Estrategia de Pruebas Automatizadas
El `package.json` no contiene scripts para ejecutar pruebas automatizadas, y la sección de "Tests unitarios" en el `README.md` está marcada como pendiente.
### Riesgos Asociados
- **Regresiones:** Sin pruebas automatizadas, es muy probable que los nuevos cambios introduzcan errores en funcionalidades existentes.
- **Dificultad para Refactorizar:** La falta de pruebas genera incertidumbre al momento de refactorizar o mejorar el código, ya que no hay una forma rápida de verificar que todo sigue funcionando correctamente.
- **Baja Calidad del Código:** La ausencia de pruebas puede llevar a un código más frágil y difícil de mantener.
### Recomendación
- **Integrar un Framework de Pruebas:** Se recomienda integrar herramientas como Jest y React Testing Library para escribir pruebas unitarias y de integración.
- **Desarrollar una Cultura de Pruebas:** Fomentar la escritura de pruebas como parte del proceso de desarrollo.
- **Implementar Pruebas E2E:** Para los flujos críticos de la aplicación, se podrían implementar pruebas End-to-End con herramientas como Cypress o Playwright.
## 3. Scripts Personalizados: Riesgos de Mantenibilidad y Seguridad
El directorio `scripts/` contiene una gran cantidad de scripts (`.js`, `.sql`, `.sh`) sin una estructura o propósito unificado. El análisis del script `scripts/verify-admin-user.js` revela problemas significativos a nivel técnico, de diseño y de seguridad.
### Razones Técnicas
- **Valores Hardcodeados:** El script contiene valores fijos (ej. `email = 'marco.gallegos@anchor2na'`). Esto lo hace inflexible y obliga a modificar el código fuente para verificar otros usuarios, aumentando el riesgo de errores.
- **Manejo de Errores Simplista:** El uso de `process.exit(1)` detiene la ejecución de forma abrupta. Un manejo de errores más robusto permitiría una integración más limpia con otros sistemas o flujos de trabajo automatizados.
- **Falta de Documentación:** La ausencia de comentarios JSDoc o bloques de descripción dificulta entender el propósito y el funcionamiento del script sin leerlo en su totalidad.
### Razones de Diseño
- **Falta de Reutilización:** El diseño del script impide su reutilización. Un enfoque mejor sería aceptar parámetros desde la línea de comandos (ej. `node verify-admin-user.js --email=test@example.com`).
- **Proliferación de Scripts:** La existencia de docenas de scripts individuales para tareas específicas sugiere la falta de una herramienta de línea de comandos (CLI) centralizada. Un buen diseño consolidaría estas operaciones en un único punto de entrada, mejorando la cohesión y el descubrimiento de funcionalidades.
### Razones de Seguridad
- **Uso de Claves con Privilegios Elevados:** El script utiliza la `SUPABASE_SERVICE_ROLE_KEY`. Esta clave tiene acceso de administrador a toda la infraestructura de Supabase y **omite todas las políticas de Row Level Security (RLS)**. Su uso en scripts locales es extremadamente peligroso.
- **Aumento de la Superficie de Ataque:** Cada script que utiliza esta clave privilegiada representa un nuevo vector de ataque. Un bug en cualquiera de estos scripts podría ser explotado para acceder, modificar o eliminar todos los datos de la aplicación.
- **Ausencia de Auditoría:** Los scripts se ejecutan localmente y solo registran en la consola. No existe un registro de auditoría centralizado que indique quién ejecutó un script con privilegios elevados, cuándo lo hizo y con qué parámetros.
### Recomendación
- **Centralizar en una CLI:** Refactorizar los scripts en una única herramienta CLI (ej. con `commander.js` o similar) que gestione los comandos, parámetros y la configuración de forma segura.
- **Limitar el Uso de Claves de Servicio:** El uso de la `SERVICE_ROLE_KEY` debe estar restringido a entornos de backend seguros y controlados, no en scripts de desarrollo. Para tareas específicas, se deberían crear roles de base de datos con permisos limitados.
- **Implementar un Sistema de Auditoría:** Registrar la ejecución de tareas administrativas críticas en una tabla de auditoría en la base de datos para tener un control de cambios y accesos.
## 4. Calidad del Código y Oportunidades de Refactorización
El componente `app/aperture/page.tsx` es un ejemplo de "God Component" que acumula demasiadas responsabilidades, lo que resulta en un código difícil de mantener, probar y razonar.
### Puntos Débiles
- **Componente "God":** El componente maneja el estado, la lógica de fetching y la renderización de múltiples pestañas (`dashboard`, `calendar`, `staff`, `payroll`, etc.), lo que viola el Principio de Responsabilidad Única.
- **Uso de `any` en TypeScript:** Se utiliza el tipo `any` para el estado de `bookings`, `staff`, `resources`, etc. Esto anula las ventajas de TypeScript, como la seguridad de tipos y el autocompletado, y puede ocultar bugs que solo aparecerán en tiempo de ejecución.
- **Lógica de Fetching Centralizada:** Toda la lógica para obtener datos de la API se encuentra en un único componente, lo que dificulta su reutilización y mantenimiento.
### Recomendación
- **Dividir el Componente:** Refactorizar el componente `ApertureDashboard` en componentes más pequeños y especializados. Cada pestaña debería ser un componente independiente con su propia lógica de estado y fetching.
- **Definir Tipos Estrictos:** Reemplazar `any` con tipos o interfaces de TypeScript que modelen la estructura de los datos (ej. `Booking`, `StaffMember`). Esto mejorará la seguridad del código y la experiencia de desarrollo.
- **Co-ubicar el Estado y la Lógica de Fetching:** Mover la lógica de obtención de datos a los componentes que la necesitan, o utilizar un gestor de estado como React Query (TanStack Query) para simplificar el fetching, el cacheo y la sincronización de datos.
## 5. Deuda Técnica y Código Heredado
Se ha identificado una cantidad considerable de deuda técnica y código heredado que podría afectar la estabilidad y el mantenimiento del proyecto.
### Puntos Débiles
- **Comentarios `TODO`:** El comando `grep -r 'TODO' .` reveló una gran cantidad de comentarios `TODO` en el código, lo que indica tareas incompletas o áreas que requieren atención.
- **Código Heredado en `app/hq`:** El directorio `app/hq` contiene una versión antigua del dashboard que ha sido reemplazada por `app/aperture`. Aunque no está directamente en uso, su presencia puede generar confusión y aumentar la complejidad del proyecto.
- **Falta de Estándares de Código:** La inconsistencia en el formato del código, el uso de `any` y la falta de comentarios sugieren la ausencia de un linter y un formateador de código configurados de manera estricta.
### Recomendación
- **Revisar y Abordar los `TODO`:** Crear tareas en el sistema de seguimiento de problemas para cada `TODO` y priorizar su resolución.
- **Eliminar el Código Heredado:** Eliminar el directorio `app/hq` y cualquier otra referencia a él para reducir la complejidad del código base.
- **Implementar Herramientas de Calidad de Código:** Configurar y hacer cumplir el uso de ESLint, Prettier y TypeScript con reglas estrictas para garantizar un estilo de código consistente y de alta calidad.