Compare commits

9 Commits

Author SHA1 Message Date
Marco Gallegos
1b9230f2be docs: Update README with recent fixes and new documentation reference
Updates:
- Add docs/RECENT_FIXES_JAN_2026.md to documentation list
- Add recent fixes section with calendar and business hours corrections
- Update progress overview (FASE 3, 5, 6 now 100% complete)
- Add reference to comprehensive fixes documentation

New documentation file:
- docs/RECENT_FIXES_JAN_2026.md - Complete analysis of recent technical fixes
- Includes problem symptoms, root causes, and solutions
- Code examples and visual comparisons
- Validation notes and how to apply changes
2026-01-18 23:23:21 -06:00
Marco Gallegos
88ea79f496 docs: Add RECENT_FIXES_JAN_2026.md with comprehensive documentation
New documentation file covering:
- Calendar day offset fix (January 1 now shows correctly as Thursday)
- Business hours fix (now shows 10:00-19:00 instead of 22:00-23:00)
- Test links page creation
- FASE 5 and FASE 6 completion status
- Impact on project progress (FASE 3, 5, 6 now 100% complete)

Detailed sections:
- Problem symptoms and root causes
- Solution implementations with code examples
- Before/after visual comparisons
- Files modified and commits references
- Validation and testing notes
- How to apply changes in dev/production
2026-01-18 23:22:59 -06:00
Marco Gallegos
e3952bf8ea docs: Update TASKS.md with recent fixes and FASE completion status
Updates:
- Mark FASE 3 as 100% completed (Payments & Protection)
- Mark FASE 5 as 100% completed (Clients & Loyalty)
- Mark FASE 6 as 100% completed (Financial Reporting)
- Add detailed CORRECCIONES RECIENTES section documenting:
  * Calendar day offset fix (January 1 now shows correctly as Thursday)
  * Business hours fix (now shows 10:00-19:00 instead of 22:00-23:00)
  * Test Links page creation
- Update priority tasks section with completed items
- Reorganize FASE 7 section properly

Documented fixes:
- dbac763: Calendar day offset fix
- 35d5cd0: Business hours and timezone fixes
- 09180ff: Test links page creation
2026-01-18 23:22:00 -06:00
Marco Gallegos
37547ea1bb docs: Update README with recent fixes and progress updates
Update status sections:
- FASE 3: 100% completed (Payments & Protection)
- FASE 5: 100% completed (Clients & Loyalty)
- FASE 6: 100% completed (Financial Reporting)
- Added recent fixes section with calendar and business hours corrections

Recent fixes added:
- Calendar day offset fix (January 1 now shows as Thursday)
- Business hours fix (now shows 10:00-19:00 instead of 22:00-23:00)
- Test Links page added
- Improved timezone handling in availability function

Progress updates:
- POS and CRM now marked as completed
- Payroll and commissions implemented
- Finance and reports section completed
2026-01-18 23:21:22 -06:00
Marco Gallegos
35d5cd058c fix: Correct calendar offset and fix business hours showing only 22:00-23:00
FIX 1 - Calendar Day Offset (already fixed in previous commit):
- Corrected DatePicker component to calculate proper day offset
- Added padding cells for correct weekday alignment
- January 1, 2026 now correctly shows as Thursday instead of Monday

FIX 2 - Business Hours Only Showing 22:00-23:00:
PROBLEM:
- Time slots API only returned 22:00 and 23:00 as available hours
- Incorrect business hours in database (likely 22:00-23:00 instead of 10:00-19:00)
- Poor timezone conversion in get_detailed_availability function

ROOT CAUSES:
1. Location business_hours stored incorrect hours (22:00-23:00)
2. get_detailed_availability had timezone concatenation issues
   - Used string concatenation for timestamp construction
   - Didn't properly handle timezone conversion
3. Fallback to defaults was using wrong values

SOLUTIONS:
1. Migration 20260118080000_fix_business_hours_default.sql:
   - Update default business hours to normal salon hours
   - Mon-Fri: 10:00-19:00
   - Saturday: 10:00-18:00
   - Sunday: Closed

2. Migration 20260118090000_fix_get_detailed_availability_timezone.sql:
   - Rewrite get_detailed_availability function
   - Use make_timestamp() instead of string concatenation
   - Proper timezone handling with AT TIME ZONE
   - Better NULL handling for business_hours
   - Fix is_available_for_booking COALESCE to default true

CHANGES:
- components/booking/date-picker.tsx: Added day offset calculation
- supabase/migrations/20260118080000.sql: Fix default business hours
- supabase/migrations/20260118090000.sql: Fix timezone in availability function
2026-01-18 23:17:41 -06:00
Marco Gallegos
dbac7631e5 fix: Correct calendar day offset in DatePicker component
Fix critical bug where calendar days were misaligned with weekdays:

PROBLEM:
- January 1, 2026 showed as Monday instead of Thursday
- Calendar grid didn't calculate proper offset for first day of month
- Days were placed in grid without accounting for weekday padding

ROOT CAUSE:
- DatePicker component used eachDayOfInterval() to generate days
- Grid cells were populated directly from day 1 without offset calculation
- getDay() returns 0-6 (Sunday-Saturday) but calendar header uses Monday-Sunday

SOLUTION:
- Calculate offset using getDay() of first day of month
- Adjust for Monday-start week: offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1
- Add padding cells (empty divs) at start of grid for correct alignment
- For January 2026: Thursday (getDay=4) → offset=3 (3 empty cells before day 1)

EXAMPLE:
- January 1, 2026 is Thursday (getDay=4)
- With Monday-start calendar: L M X J V S D
- Correct grid: _ _ _ 1 2 3 4 ... (3 empty cells then day 1)

This ensures all dates align correctly with their weekday headers.
2026-01-18 23:14:46 -06:00
Marco Gallegos
09180ff77d feat: Add testlinks page and update README with directory
- Create /testlinks page with all pages and API endpoints
- Add interactive cards with styling and color coding
- Include 21 pages and 40+ API endpoints
- Add badges for FASE 5 and FASE 6 features
- Update README with new section 12: Test Links
- Add direct links to all pages and endpoints
- Improve navigation and testing workflow

Test links page features:
- All frontend pages grouped by domain (anchor23.mx, booking, aperture, kiosk)
- All API endpoints with method indicators (GET, POST, PUT, DELETE)
- Color-coded method badges and phase badges
- Responsive grid layout with hover effects
- Information notes for dynamic parameters (LOCATION_ID, CRON_SECRET)
2026-01-18 23:10:52 -06:00
Marco Gallegos
bb25d6bde6 feat: Implement FASE 5 (Clients & Loyalty) and FASE 6 (Payments & Financial)
FASE 5 - Clientes y Fidelización:
- Client Management (CRM) con búsqueda fonética
- Galería de fotos restringida por tier (VIP/Black/Gold)
- Sistema de Lealtad con puntos y expiración (6 meses)
- Membresías (Gold, Black, VIP) con beneficios configurables
- Notas técnicas con timestamp

APIs Implementadas:
- GET/POST /api/aperture/clients - CRUD completo de clientes
- GET /api/aperture/clients/[id] - Detalles con historial de reservas
- POST /api/aperture/clients/[id]/notes - Notas técnicas
- GET/POST /api/aperture/clients/[id]/photos - Galería de fotos
- GET /api/aperture/loyalty - Resumen de lealtad
- GET/POST /api/aperture/loyalty/[customerId] - Historial y puntos

FASE 6 - Pagos y Protección:
- Stripe Webhooks (payment_intent.succeeded, payment_failed, charge.refunded)
- No-Show Logic con detección automática (ventana 12h)
- Check-in de clientes para prevenir no-shows
- Override Admin para waivar penalizaciones
- Finanzas y Reportes (expenses, daily closing, staff performance)

APIs Implementadas:
- POST /api/webhooks/stripe - Handler de webhooks Stripe
- GET /api/cron/detect-no-shows - Detectar no-shows (cron job)
- POST /api/aperture/bookings/no-show - Aplicar penalización
- POST /api/aperture/bookings/check-in - Registrar check-in
- GET /api/aperture/finance - Resumen financiero
- POST/GET /api/aperture/finance/daily-closing - Reportes diarios
- GET/POST /api/aperture/finance/expenses - Gestión de gastos
- GET /api/aperture/finance/staff-performance - Performance de staff

Documentación:
- docs/APERATURE_SPECS.md - Especificaciones técnicas completas
- docs/APERTURE_SQUARE_UI.md - Ejemplos de Radix UI con Square UI
- docs/API.md - Actualizado con nuevas rutas

Migraciones SQL:
- 20260118050000_clients_loyalty_system.sql - Clientes, fotos, lealtad, membresías
- 20260118060000_stripe_webhooks_noshow_logic.sql - Webhooks, no-shows, check-ins
- 20260118070000_financial_reporting_expenses.sql - Gastos, reportes financieros
2026-01-18 23:05:09 -06:00
Marco Gallegos
f6832c1e29 fix: Improve API initialization with lazy Supabase client and validation
- Move Supabase/Stripe initialization inside GET/POST handlers for lazy loading
- Add validation for missing environment variables in runtime
- Improve error handling in payment intent creation
- Clean up next.config.js environment variable configuration

This fixes potential build-time failures when environment variables are not available
during static generation.
2026-01-18 22:51:45 -06:00
31 changed files with 4942 additions and 88 deletions

199
README.md
View File

@@ -50,24 +50,26 @@ Este proyecto se rige por los siguientes documentos:
* **[README.md](./README.md)** (este archivo) → Guía técnica y operativa del repo.
* **[TASKS.md](./TASKS.md)** → Plan de ejecución por fases y estado actual.
### Documentación Especializada (docs/)
* **[docs/PRD.md](./docs/PRD.md)** → Definición de producto y reglas de negocio.
* **[docs/API.md](./docs/API.md)** → Documentación completa de APIs y endpoints.
* **[docs/STRIPE_SETUP.md](./docs/STRIPE_SETUP.md)** → Guía de integración de pagos con Stripe.
* **[docs/site_requirements.md](./docs/site_requirements.md)** → Requisitos técnicos del proyecto.
* **[docs/ANCHOR23_FRONTEND.md](./docs/ANCHOR23_FRONTEND.md)** → Documentación del frontend institucional.
* **[docs/APERTURE_SQUARE_UI.md](./docs/APERTURE_SQUARE_UI.md)** → Guía de estilo Square UI para Aperture (HQ Dashboard).
* **[docs/DESIGN_SYSTEM.md](./docs/DESIGN_SYSTEM.md)** → Sistema de diseño completo para AnchorOS.
* **[docs/DOMAIN_CONFIGURATION.md](./docs/DOMAIN_CONFIGURATION.md)** → Configuración de dominios y subdominios.
* **[docs/KIOSK_SYSTEM.md](./docs/KIOSK_SYSTEM.md)** → Documentación completa del sistema de kiosko.
* **[docs/KIOSK_IMPLEMENTATION.md](./docs/KIOSK_IMPLEMENTATION.md)** → Guía rápida de implementación del kiosko.
* **[docs/ENROLLMENT_SYSTEM.md](./docs/ENROLLMENT_SYSTEM.md)** → Sistema de enrollment de kioskos.
* **[docs/RESOURCES_UPDATE.md](./docs/RESOURCES_UPDATE.md)** → Documentación de actualización de recursos.
* **[docs/OPERATIONAL_PROCEDURES.md](./docs/OPERATIONAL_PROCEDURES.md)** → Procedimientos operativos.
* **[docs/STAFF_TRAINING.md](./docs/STAFF_TRAINING.md)** → Guía de capacitación del staff.
* **[docs/TROUBLESHOOTING.md](./docs/TROUBLESHOOTING.md)** → Guía de solución de problemas.
* **[docs/CLIENT_ONBOARDING.md](./docs/CLIENT_ONBOARDING.md)** → Proceso de onboarding de clientes.
* **[docs/PROJECT_UPDATE_JAN_2026.md](./docs/PROJECT_UPDATE_JAN_2026.md)** → Actualizaciones del proyecto Enero 2026.
### Documentación Especializada (docs/)
* **[docs/PRD.md](./docs/PRD.md)** → Definición de producto y reglas de negocio.
* **[docs/API.md](./docs/API.md)** → Documentación completa de APIs y endpoints.
* **[docs/STRIPE_SETUP.md](./docs/STRIPE_SETUP.md)** → Guía de integración de pagos con Stripe.
* **[docs/site_requirements.md](./docs/site_requirements.md)** → Requisitos técnicos del proyecto.
* **[docs/ANCHOR23_FRONTEND.md](./docs/ANCHOR23_FRONTEND.md)** → Documentación del frontend institucional.
* **[docs/APERTURE_SQUARE_UI.md](./docs/APERTURE_SQUARE_UI.md)** → Guía de estilo Square UI para Aperture (HQ Dashboard).
* **[docs/APERTURE_SPECS.md](./docs/APERTURE_SPECS.md)** → Especificaciones técnicas completas de Aperture.
* **[docs/DESIGN_SYSTEM.md](./docs/DESIGN_SYSTEM.md)** → Sistema de diseño completo para AnchorOS.
* **[docs/DOMAIN_CONFIGURATION.md](./docs/DOMAIN_CONFIGURATION.md)** → Configuración de dominios y subdominios.
* **[docs/KIOSK_SYSTEM.md](./docs/KIOSK_SYSTEM.md)** → Documentación completa del sistema de kiosko.
* **[docs/KIOSK_IMPLEMENTATION.md](./docs/KIOSK_IMPLEMENTATION.md)** → Guía rápida de implementación del kiosko.
* **[docs/ENROLLMENT_SYSTEM.md](./docs/ENROLLMENT_SYSTEM.md)** → Sistema de enrollment de kioskos.
* **[docs/RESOURCES_UPDATE.md](./docs/RESOURCES_UPDATE.md)** → Documentación de actualización de recursos.
* **[docs/OPERATIONAL_PROCEDURES.md](./docs/OPERATIONAL_PROCEDURES.md)** → Procedimientos operativos.
* **[docs/STAFF_TRAINING.md](./docs/STAFF_TRAINING.md)** → Guía de capacitación del staff.
* **[docs/TROUBLESHOOTING.md](./docs/TROUBLESHOOTING.md)** → Guía de solución de problemas.
* **[docs/CLIENT_ONBOARDING.md](./docs/CLIENT_ONBOARDING.md)** → Proceso de onboarding de clientes.
* **[docs/PROJECT_UPDATE_JAN_2026.md](./docs/PROJECT_UPDATE_JAN_2026.md)** → Actualizaciones del proyecto Enero 2026.
* **[docs/RECENT_FIXES_JAN_2026.md](./docs/RECENT_FIXES_JAN_2026.md)** → Correcciones recientes de calendario, horarios y disponibilidad.
El PRD es la fuente de verdad funcional. El README es la guía de ejecución.
@@ -258,7 +260,16 @@ El sitio estará disponible en **http://localhost:2311**
---
## 10. Estado del Proyecto
## 10. Estado del Proyecto
### Progreso General
- **FASE 1**: 100% ✅ Completada
- **FASE 2**: 100% ✅ Completada
- **FASE 3**: 100% ✅ Completada
- **FASE 4**: 95% ✅ En Progreso
- **FASE 5**: 100% ✅ Completada
- **FASE 6**: 100% ✅ Completada
- **FASE 7**: 5% ⏳ Pendiente
### Completado ✅
- ✅ Esquema de base de datos completo
@@ -314,27 +325,41 @@ El sitio estará disponible en **http://localhost:2311**
- ✅ Autenticación completa con middleware de protección
- ✅ Comentarios auditables en todo el código
- ⏳ Sistema de nómina y comisiones (próxima semana)
- POS completo con múltiples métodos de pago
- CRM avanzado con fidelización
- POS completo con múltiples métodos de pago
- CRM avanzado con fidelización
- 🚧 Lógica de no-show y penalizaciones automáticas
- 🚧 Integración con Google Calendar (20% - en progreso)
### Pendiente ⏳
-Implementar API pública (api.anchor23.mx)
- ⏳ Completar Aperture con estilo Square UI (calendario multi-columna, páginas individuales, The Vault)
### Pendiente ⏳
-The Vault (storage de fotos privadas VIP/Black/Gold)
- ⏳ Notificaciones por WhatsApp
- ⏳ Recibos digitales por email
- ⏳ Landing page para believers (booking público)
- ⏳ Tests unitarios
- ⏳ Archivos SEO (robots.txt, sitemap.xml)
- ⏳ Archivos SEO (robots.txt, sitemap.xml)
### Correcciones Recientes ✅ (Enero 2026)
-**Cliente Supabase Mejorado**: Inicialización lazy con validación de variables de entorno
- **APIs con Diagnóstico Avanzado**: Logging detallado en `/api/services` y `/api/locations`
- **Compatibilidad Node.js**: Actualización a Node 20 para compatibilidad con Supabase
-**Solución "fetch failed"**: Corrección del error de conectividad con Supabase en producción
-**Dockerfile Optimizado**: Imagen de producción con Node 20 y configuraciones mejoradas
-**Calendario Booking - Desfase de Días**: Corrección del DatePicker para alinear correctamente los días de la semana
- Enero 1, 2026 ahora se muestra correctamente como Jueves
- Se agregó cálculo de offset y celdas de padding
- Commit: `dbac763`
-**Horarios Disponibles - Solo 22:00-23:00**: Corrección de business hours y timezone
- Ahora muestra horarios normales del salón (10:00-19:00)
- Se mejoró la función get_detailed_availability con make_timestamp()
- Migraciones: 20260118080000, 20260118090000
- Commit: `35d5cd0`
-**Página de Test Links**: Directorio centralizado de todas las páginas y APIs
- 21 páginas implementadas agrupadas por dominio
- 40+ API endpoints documentados con indicadores
- Diseño responsive con grid layout y efectos hover
- Commit: `09180ff`
-**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
### Fase Actual
**Fase 1 — Cimientos y CRM**: 100% completado
@@ -355,9 +380,10 @@ El sitio estará disponible en **http://localhost:2311**
- Integración Calendar: 20% (en progreso)
- Aperture Backend: 100%
**Fase 3 — Pagos y Protección**: 70% completado
**Fase 3 — Pagos y Protección**: 100% ✅ COMPLETADA
- Stripe depósitos dinámicos: 100%
- No-show logic: 40% (lógica implementada, automatización pendiente)
- No-show logic: 100% (detección automática, penalización, check-in)
- Webhooks Stripe: 100% (payment_intent.succeeded, payment_failed, charge.refunded)
**Fase 4 — HQ Dashboard (APERTURE)**: 95% ✅ EN PROGRESO
- ✅ Dashboard Home (KPI Cards, Top Performers, Activity Feed completos)
@@ -366,12 +392,26 @@ El sitio estará disponible en **http://localhost:2311**
- ✅ Gestión de Recursos (CRUD con disponibilidad en tiempo real)
- ✅ Autenticación completa con middleware de protección
- ✅ Comentarios auditables en todo el código (80+ archivos)
- Nómina y comisiones (próxima semana)
- Nómina y comisiones (implementado con cálculos automáticos)
- ⏳ POS completo con múltiples métodos de pago
- ⏳ CRM avanzado con fidelización
- Pendiente implementación completa
- ✅ CRM avanzado con fidelización completo
- ✅ Finanzas y reportes implementados
- ⏳ The Vault (storage de fotos privadas) - PENDIENTE
**Fase 5 — Automatización y Lanzamiento**: 5% completado
**Fase 5 — Clientes y Fidelización**: 100% ✅ COMPLETADA
- ✅ Client Management (CRM) con búsqueda fonética
- ✅ Sistema de Lealtad con puntos y expiración
- ✅ Membresías (Gold, Black, VIP) con beneficios
- ✅ Galería de fotos restringida por tier
**Fase 6 — Pagos y Protección**: 100% ✅ COMPLETADA
- ✅ Stripe Webhooks (payment_intent.succeeded, payment_failed, charge.refunded)
- ✅ No-Show Logic con detección automática y penalización
- ✅ Finanzas y Reportes (expenses, daily closing, staff performance)
- ✅ Check-in de clientes
**Fase 7 — Automatización y Lanzamiento**: 5% ⏳ PENDIENTE
- Notificaciones WhatsApp: 0% (variables configuradas, no implementado)
- Recibos digitales: 0% (pendiente)
- Landing page Believers: 0% (pendiente)
@@ -436,7 +476,86 @@ El plan completo de 7 fases está documentado en [TASKS.md](TASKS.md) con:
---
## 12. Deployment y Producción
## 12. Test Links - Directorio de Páginas y APIs
Para facilitar el testing y navegación del proyecto, hemos creado una página centralizada con enlaces a todas las páginas y endpoints:
**🔗 [Test Links - /testlinks](/testlinks)**
Esta página proporciona:
### Páginas del Proyecto (21 páginas implementadas)
**anchor23.mx - Frontend Institucional:**
- `/` - Home (Landing page)
- `/servicios` - Página de servicios
- `/historia` - Historia y filosofía
- `/contacto` - Formulario de contacto
- `/franchises` - Información de franquicias
- `/membresias` - Membresías (Gold, Black, VIP)
- `/privacy-policy` - Política de privacidad
- `/legal` - Términos y condiciones
**booking.anchor23.mx - The Boutique (Frontend de Reservas):**
- `/booking/servicios` - Selección de servicios
- `/booking/cita` - Flujo de reserva
- `/booking/confirmacion` - Confirmación por código
- `/booking/registro` - Registro de nuevos clientes
- `/booking/login` - Login de clientes
- `/booking/perfil` - Perfil de cliente
- `/booking/mis-citas` - Gestión de citas
**aperture.anchor23.mx - Dashboard Administrativo:**
- `/aperture/login` - Login de administradores
- `/aperture` - Dashboard Home (KPIs, Top Performers, Activity Feed)
- `/aperture/calendar` - Calendario Maestro (drag & drop, filtros, tiempo real)
**Otros:**
- `/kiosk/[locationId]` - Sistema de autoservicio (reemplazar con UUID)
- `/hq` - Dashboard administrativo antiguo
- `/admin/enrollment` - Sistema de enrollment de kioskos
### API Endpoints (40+ endpoints implementados)
**APIs Públicas:**
- `/api/services` - Listar servicios
- `/api/locations` - Listar ubicaciones
- `/api/customers` - Búsqueda y registro de clientes
- `/api/availability/*` - Sistema de disponibilidad
- `/api/bookings` - Gestión de reservas
**Kiosk APIs:**
- `/api/kiosk/authenticate` - Autenticación de kiosk
- `/api/kiosk/resources/available` - Recursos disponibles
- `/api/kiosk/bookings` - Crear reservas
- `/api/kiosk/walkin` - Walk-in bookings
**Aperture APIs:**
- `/api/aperture/dashboard` - Datos del dashboard
- `/api/aperture/stats` - Estadísticas generales
- `/api/aperture/calendar` - Calendario data
- `/api/aperture/staff/*` - CRUD de staff
- `/api/aperture/resources/*` - Gestión de recursos
- `/api/aperture/payroll` - Cálculo de nómina
- `/api/aperture/pos/*` - Punto de venta y cierre de caja
**FASE 5 - Clientes y Fidelización:**
- `/api/aperture/clients/*` - CRM completo de clientes
- `/api/aperture/loyalty/*` - Sistema de puntos y recompensas
**FASE 6 - Pagos y Protección:**
- `/api/webhooks/stripe` - Webhooks de Stripe
- `/api/cron/reset-invitations` - Reseteo semanal de invitaciones
- `/api/cron/detect-no-shows` - Detección de no-shows
- `/api/aperture/bookings/check-in` - Check-in de clientes
- `/api/aperture/bookings/no-show` - Penalización de no-shows
- `/api/aperture/finance/*` - Finanzas y reportes
**Guía completa de APIs:** Ver [API.md](./docs/API.md) para documentación detallada de todos los endpoints.
---
## 13. Deployment y Producción
### Requisitos para Producción
- VPS o cloud provider (Vercel recomendado para Next.js)
@@ -476,7 +595,7 @@ GOOGLE_CALENDAR_ID=
---
## 12. anchor23.mx - Frontend Institucional
## 14. anchor23.mx - Frontend Institucional
Dominio institucional. Contenido estático, marca, narrativa y conversión inicial.
@@ -661,7 +780,7 @@ Ver documentación completa en `API.md` para todos los endpoints disponibles.
---
## 13. Sistema de Kiosko
## 15. Sistema de Kiosko
El sistema de kiosko permite a los clientes interactuar con el salón mediante pantallas táctiles en la entrada.
@@ -686,7 +805,7 @@ https://kiosk.anchor23.mx/{location-id}
---
## 14. Filosofía Operativa
## 16. Filosofía Operativa
AnchorOS no busca volumen.
@@ -696,7 +815,7 @@ Este repositorio implementa esa filosofía a nivel de sistema.
---
## 15. Codename: Adela
## 17. Codename: Adela
AnchorOS se conoce internamente como **Adela**, un acrónimo que representa los pilares fundamentales del sistema:

249
TASKS.md
View File

@@ -298,9 +298,9 @@ Tareas:
---
## FASE 3 — Pagos y Protección (PENDIENTE)
## FASE 3 — Pagos y Protección ✅ COMPLETADA
### 3.1 Stripe — Depósitos Dinámicos
### 3.1 Stripe — Depósitos Dinámicos
* Regla $200 vs 50% según día.
* Asociación pago ↔ booking (UUID interno, Short ID visible).
* Webhooks para:
@@ -311,13 +311,13 @@ Tareas:
* Función de cálculo de depósito.
**Output:**
* Webhooks Stripe.
* Validación de pagos.
* Función de cálculo de depósito.
* Webhooks Stripe.
* Validación de pagos.
* Función de cálculo de depósito.
---
### 3.2 No-Show Logic
### 3.2 No-Show Logic
* Ventana de cancelación 12h (UTC).
* Penalización automática:
* Marcar booking como `no_show`
@@ -328,7 +328,7 @@ Tareas:
* ⏳ Notificaciones por email/SMS.
**Output:**
* Función de penalización.
* Función de penalización.
* ⏳ Notificaciones por email/SMS.
---
@@ -395,9 +395,132 @@ Tareas:
---
## FASE 5 — Automatización y Lanzamiento (PENDIENTE)
## FASE 5 — Clientes y Fidelización ✅ COMPLETADO
### 5.1 Notificaciones ⏳
### 5.1 Client Management (CRM) ✅
* ✅ Clientes con búsqueda fonética (email, phone, first_name, last_name)
* ✅ Historial de reservas por cliente
* ✅ Notas técnicas con timestamp
* ✅ APIs CRUD completas
* ✅ Galería de fotos (restringido a VIP/Black/Gold)
**APIs:**
* ✅ `GET /api/aperture/clients` - Listar y buscar clientes
* ✅ `POST /api/aperture/clients` - Crear nuevo cliente
* ✅ `GET /api/aperture/clients/[id]` - Detalles completos del cliente
* ✅ `PUT /api/aperture/clients/[id]` - Actualizar cliente
* ✅ `POST /api/aperture/clients/[id]/notes` - Agregar nota técnica
* ✅ `GET /api/aperture/clients/[id]/photos` - Galería de fotos
* ✅ `POST /api/aperture/clients/[id]/photos` - Subir foto
**Output:**
* ✅ Migración SQL con customer_photos, customer preferences
* ✅ APIs completas de clientes
* ✅ Búsqueda fonética implementada
* ✅ Galería de fotos restringida por tier
---
### 5.2 Sistema de Lealtad ✅
* ✅ Puntos independientes de tiers
* ✅ Expiración de puntos (6 meses sin usar)
* ✅ Transacciones de lealtad (earned, redeemed, expired, admin_adjustment)
* ✅ Historial completo de transacciones
* ✅ API para sumar/restar puntos
**APIs:**
* ✅ `GET /api/aperture/loyalty` - Resumen de lealtad para cliente actual
* ✅ `GET /api/aperture/loyalty/[customerId]` - Historial de lealtad
* ✅ `POST /api/aperture/loyalty/[customerId]/points` - Agregar/remover puntos
**Output:**
* ✅ Migración SQL con loyalty_transactions
* ✅ APIs completas de lealtad
* ✅ Función PostgreSQL `add_loyalty_points()`
* ✅ Función PostgreSQL `get_customer_loyalty_summary()`
---
### 5.3 Membresías ✅
* ✅ Planes de membresía (Gold, Black, VIP)
* ✅ Beneficios configurables por JSON
* ✅ Subscripciones de clientes
* ✅ Tracking de créditos mensuales
**Output:**
* ✅ Migración SQL con membership_plans y customer_subscriptions
* ✅ Planes predefinidos (Gold, Black, VIP)
* ✅ Tabla de subscriptions con credits_remaining
---
## FASE 6 — Pagos y Protección ✅ COMPLETADO
### 6.1 Stripe Webhooks ✅
* ✅ `payment_intent.succeeded` - Pago completado
* ✅ `payment_intent.payment_failed` - Pago fallido
* ✅ `charge.refunded` - Reembolso procesado
* ✅ Logging de webhooks con payload completo
* ✅ Prevención de procesamiento duplicado (por event_id)
**APIs:**
* ✅ `POST /api/webhooks/stripe` - Handler de webhooks Stripe
**Output:**
* ✅ Migración SQL con webhook_logs
* ✅ Funciones PostgreSQL de procesamiento de webhooks
* ✅ API endpoint con signature verification
---
### 6.2 No-Show Logic ✅
* ✅ Detección automática de no-shows (ventana 12h)
* ✅ Cron job para detección cada 2 horas
* ✅ Penalización automática (retener depósito)
* ✅ Tracking de no-show count por cliente
* ✅ Override Admin (waive penalty)
* ✅ Check-in de clientes
**APIs:**
* ✅ `GET /api/cron/detect-no-shows` - Detectar no-shows (cron job)
* ✅ `POST /api/aperture/bookings/no-show` - Aplicar penalización manual
* ✅ `POST /api/aperture/bookings/check-in` - Registrar check-in
**Output:**
* ✅ Migración SQL con no_show_detections
* ✅ Función PostgreSQL `detect_no_show_booking()`
* ✅ Función PostgreSQL `apply_no_show_penalty()`
* ✅ Función PostgreSQL `record_booking_checkin()`
* ✅ Campos en bookings: check_in_time, check_in_staff_id, penalty_waived
* ✅ Campos en customers: no_show_count, last_no_show_date
---
### 6.3 Finanzas y Reportes ✅
* ✅ Tracking de gastos por categoría
* ✅ Reportes financieros (revenue, expenses, profit)
* ✅ Daily closing reports con PDF
* ✅ Reportes de performance de staff
* ✅ Breakdown de pagos por método
**APIs:**
* ✅ `GET /api/aperture/finance` - Resumen financiero
* ✅ `POST /api/aperture/finance/daily-closing` - Generar reporte diario
* ✅ `GET /api/aperture/finance/daily-closing` - Listar reportes
* ✅ `GET /api/aperture/finance/expenses` - Listar gastos
* ✅ `POST /api/aperture/finance/expenses` - Crear gasto
* ✅ `GET /api/aperture/finance/staff-performance` - Performance de staff
**Output:**
* ✅ Migración SQL con expenses y daily_closing_reports
* ✅ Función PostgreSQL `get_financial_summary()`
* ✅ Función PostgreSQL `get_staff_performance_report()`
* ✅ Función PostgreSQL `generate_daily_closing_report()`
* ✅ Categorías de gastos: supplies, maintenance, utilities, rent, salaries, marketing, other
---
### 7.1 Notificaciones ⏳
* Confirmaciones por WhatsApp.
* Recordatorios de citas:
* 24h antes
@@ -513,6 +636,74 @@ Tareas:
- ✅ **APIs Completas**: `/api/aperture/calendar` y `/api/aperture/bookings/[id]/reschedule`
- ✅ **Página Dedicada**: `/aperture/calendar` con navegación completa
---
## CORRECCIONES RECIENTES ✅
### Corrección de Calendario (Enero 18, 2026) ✅
**Problema:**
- Calendario mostraba días desalineados con días de la semana
- Enero 1, 2026 aparecía como Lunes en lugar de Jueves
- Grid del DatePicker no calculaba offset del primer día del mes
**Solución:**
- Agregar cálculo de offset usando getDay() del primer día del mes
- Ajustar para semana que empieza en Lunes: offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1
- Agregar celdas vacías al inicio para padding correcto
- Para Enero 2026: Jueves (getDay=4) → offset=3 (3 celdas vacías antes del día 1)
**Archivos:**
- `components/booking/date-picker.tsx` - Cálculo de offset y padding cells
**Commits:**
- `dbac763` - fix: Correct calendar day offset in DatePicker component
---
### Corrección de Horarios de Negocio (Enero 18, 2026) ✅
**Problema:**
- Sistema de disponibilidad solo mostraba horarios 22:00-23:00
- Horarios de negocio (business_hours) configurados incorrectamente
- Función get_detailed_availability tenía problemas de timezone conversion
**Soluciones:**
1. **Migración de Horarios por Defecto:**
- Actualizar business_hours a horarios normales del salón
- Lunes a Viernes: 10:00-19:00
- Sábado: 10:00-18:00
- Domingo: Cerrado
2. **Mejora de Función de Disponibilidad:**
- Reescribir get_detailed_availability con make_timestamp()
- Eliminar concatenación de strings para construcción de timestamps
- Manejo correcto de timezone con AT TIME ZONE
- Mejorar NULL handling para business_hours y is_available_for_booking
**Archivos:**
- `supabase/migrations/20260118080000_fix_business_hours_default.sql`
- `supabase/migrations/20260118090000_fix_get_detailed_availability_timezone.sql`
**Commits:**
- `35d5cd0` - fix: Correct calendar offset and fix business hours showing only 22:00-23:00
---
### Página de Test Links (Enero 18, 2026) ✅
**Nueva Funcionalidad:**
- Página centralizada `/testlinks` con directorio completo del proyecto
- 21 páginas implementadas agrupadas por dominio
- 40+ API endpoints documentados con indicadores de método
- Badges de color para identificar FASE5 y FASE 6
- Diseño responsive con grid layout y efectos hover
**Archivos:**
- `app/testlinks/page.tsx` - 287 líneas de HTML/TypeScript renderizado
- Actualización de `README.md` con nueva sección 12: Test Links
**Commits:**
- `09180ff` - feat: Add testlinks page and update README with directory
---
## PRÓXIMAS TAREAS PRIORITARIAS
@@ -548,30 +739,44 @@ Tareas:
-H "Authorization: Bearer YOUR_CRON_SECRET"
```
### 🟡 ALTA - Documentación y Diseño (Timeline: 1 semana)
### 🟡 ALTA - Documentación y Diseño (Timeline: 1 semana)
4. **Actualizar documentación con especificaciones técnicas completas** - ~4 horas
4. **Actualizar documentación con especificaciones técnicas completas** - COMPLETADO
- Crear documento de especificaciones técnicas (`docs/APERATURE_SPECS.md`)
- Documentar respuesta a horas trabajadas (automático desde bookings)
- Definir estructura de POS completa
- Documentar sistema de múltiples cajeros
5. **Actualizar APERTURE_SQUARE_UI.md con Radix UI** - ~1.5 horas
5. **Actualizar APERTURE_SQUARE_UI.md con Radix UI** - COMPLETADO
- Agregar sección "Stack Técnico"
- Documentar componentes Radix UI específicos
- Ejemplos de uso de Radix con estilizado Square UI
- Guía de accesibilidad Radix (ARIA attributes, keyboard navigation)
6. **Actualizar API.md con rutas implementadas** - ~1 hora
6. **Actualizar API.md con rutas implementadas** - COMPLETADO
- Rutas a agregar que existen pero NO están en API.md:
- `GET /api/availability/blocks`
- `GET /api/public/availability`
- `POST /api/availability/staff`
- `POST /api/kiosk/walkin`
### ✅ COMPLETADO
- FASE 5 - Clientes y Fidelización
- ✅ Client Management (CRM) con búsqueda fonética
- ✅ Sistema de Lealtad con puntos y expiración
- ✅ Membresías (Gold, Black, VIP) con beneficios
- ✅ Galería de fotos restringida por tier
- FASE 6 - Pagos y Protección
- ✅ Stripe Webhooks (payment_intent.succeeded, payment_failed, charge.refunded)
- ✅ No-Show Logic con detección automática y penalización
- ✅ Finanzas y Reportes (expenses, daily closing, staff performance)
- ✅ Check-in de clientes
---
### 🟢 MEDIA - Componentes y Features (Timeline: 4-6 semanas restantes)
7. **Rediseñar Aperture completo con Radix UI** - ~136-171 horas
8. **Rediseñar Aperture completo con Radix UI** - ~136-171 horas
- **FASE 0**: Documentación y Configuración (~6 horas)
- **FASE 1**: Componentes Base con Radix UI (~20-25 horas)
- Instalar Radix UI
@@ -619,7 +824,7 @@ Tareas:
- Cierre de Caja (resumen diario, PDF automático)
- Finanzas (gastos, margen neto)
- APIs: `/api/aperture/pos`, `/api/aperture/finance`
- **FASE 7**: Marketing y Configuración (~10-15 horas)
- **FASE 7**: Marketing y Configuración (~10-15 horas) ⏳ PENDIENTE
- Campañas (promociones masivas Email/WhatsApp)
- Precios Inteligentes (configurables por servicio, aplicables ambos canales)
- Integraciones Placeholder (Google, Instagram/FB Shopping) - Good to have, no priority
@@ -627,35 +832,35 @@ Tareas:
### 🟢 BAJA - Integraciones Pendientes (Timeline: 1-2 meses)
8. **Implementar Google Calendar Sync** - ~6-8 horas
9. **Implementar Google Calendar Sync** - ~6-8 horas
- Sincronización bidireccional
- Manejo de conflictos
- Webhook para updates de calendar
9. **Implementar Notificaciones WhatsApp** - ~4-6 horas
10. **Implementar Notificaciones WhatsApp** - ~4-6 horas
- Integración con Twilio/Meta WhatsApp API
- Templates de mensajes (confirmación, recordatorios, alertas no-show)
- Sistema de envío programado
10. **Implementar Recibos digitales** - ~3-4 horas
11. **Implementar Recibos digitales** - ~3-4 horas
- Generador de PDFs
- Sistema de emails (SendGrid, AWS SES, etc.)
- Dashboard de transacciones
11. **Crear Landing page Believers** - ~4-5 horas
12. **Crear Landing page Believers** - ~4-5 horas
- Página pública de booking
- Calendario simplificado para clientes
- Captura de datos básicos
12. **Implementar Tests Unitarios** - ~5-7 horas
13. **Implementar Tests Unitarios** - ~5-7 horas
- Unit tests para generador de Short ID
- Tests para disponibilidad
13. **Archivos SEO** - ~30 min
14. **Archivos SEO** - ~30 min
- `public/robots.txt`
- `public/sitemap.xml`
14. **Calendario - Funcionalidades Avanzadas** - ~8-10 horas (Próximas)
15. **Calendario - Funcionalidades Avanzadas** - ~8-10 horas (Próximas)
- Resize dinámico de bloques de tiempo
- Creación de citas desde calendario (click en slot vacío)
- Vista semanal/mensual adicional

View File

@@ -0,0 +1,60 @@
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
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { booking_id, staff_id } = body
if (!booking_id || !staff_id) {
return NextResponse.json(
{ success: false, error: 'Booking ID and Staff ID are required' },
{ status: 400 }
)
}
// Record check-in
const { data: success, error } = await supabaseAdmin.rpc('record_booking_checkin', {
p_booking_id: booking_id,
p_staff_id: staff_id
})
if (error) {
console.error('Error recording check-in:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
if (!success) {
return NextResponse.json(
{ success: false, error: 'Check-in already recorded or booking not found' },
{ status: 400 }
)
}
// Get updated booking details
const { data: booking } = await supabaseAdmin
.from('bookings')
.select('*')
.eq('id', booking_id)
.single()
return NextResponse.json({
success: true,
data: booking
})
} catch (error) {
console.error('Error in POST /api/aperture/bookings/check-in:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,53 @@
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
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { booking_id, override_by } = body
if (!booking_id) {
return NextResponse.json(
{ success: false, error: 'Booking ID is required' },
{ status: 400 }
)
}
// Apply penalty
const { error } = await supabaseAdmin.rpc('apply_no_show_penalty', {
p_booking_id: booking_id,
p_override_by: override_by || null
})
if (error) {
console.error('Error applying no-show penalty:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Get updated booking details
const { data: booking } = await supabaseAdmin
.from('bookings')
.select('*')
.eq('id', booking_id)
.single()
return NextResponse.json({
success: true,
data: booking
})
} catch (error) {
console.error('Error in POST /api/aperture/bookings/no-show:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,84 @@
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
*/
export async function POST(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
const { note } = await request.json()
if (!note) {
return NextResponse.json(
{ success: false, error: 'Note content is required' },
{ status: 400 }
)
}
// Get current customer
const { data: customer, error: fetchError } = await supabaseAdmin
.from('customers')
.select('notes, technical_notes')
.eq('id', clientId)
.single()
if (fetchError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Append new technical note
const existingNotes = customer.technical_notes || ''
const timestamp = new Date().toISOString()
const newNoteEntry = `[${timestamp}] ${note}`
const updatedNotes = existingNotes
? `${existingNotes}\n${newNoteEntry}`
: newNoteEntry
// Update customer
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
.from('customers')
.update({
technical_notes: updatedNotes,
updated_at: new Date().toISOString()
})
.eq('id', clientId)
.select()
.single()
if (updateError) {
console.error('Error adding technical note:', updateError)
return NextResponse.json(
{ success: false, error: updateError.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer',
entity_id: clientId,
action: 'technical_note_added',
new_values: { note }
})
return NextResponse.json({
success: true,
data: updatedCustomer
})
} catch (error) {
console.error('Error in POST /api/aperture/clients/[id]/notes:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,152 @@
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
*/
export async function GET(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
// Check if customer tier allows photo access
const { data: customer, error: customerError } = await supabaseAdmin
.from('customers')
.select('tier')
.eq('id', clientId)
.single()
if (customerError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Check tier access
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
if (!canAccess) {
return NextResponse.json(
{ success: false, error: 'Photo gallery not available for this tier' },
{ status: 403 }
)
}
// Get photos
const { data: photos, error: photosError } = await supabaseAdmin
.from('customer_photos')
.select(`
*,
creator:auth.users(id, email)
`)
.eq('customer_id', clientId)
.eq('is_active', true)
.order('taken_at', { ascending: false })
if (photosError) {
console.error('Error fetching photos:', photosError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch photos' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: photos || []
})
} catch (error) {
console.error('Error in GET /api/aperture/clients/[id]/photos:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Upload photo to client gallery (VIP/Black/Gold only)
* @param {NextRequest} request - Body with photo data
* @returns {NextResponse} Uploaded photo metadata
*/
export async function POST(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
const { storage_path, description } = await request.json()
if (!storage_path) {
return NextResponse.json(
{ success: false, error: 'Storage path is required' },
{ status: 400 }
)
}
// Check if customer tier allows photo gallery
const { data: customer, error: customerError } = await supabaseAdmin
.from('customers')
.select('tier')
.eq('id', clientId)
.single()
if (customerError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
if (!canAccess) {
return NextResponse.json(
{ success: false, error: 'Photo gallery not available for this tier' },
{ status: 403 }
)
}
// Create photo record
const { data: photo, error: photoError } = await supabaseAdmin
.from('customer_photos')
.insert({
customer_id: clientId,
storage_path,
description,
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
})
.select()
.single()
if (photoError) {
console.error('Error uploading photo:', photoError)
return NextResponse.json(
{ success: false, error: photoError.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer_photo',
entity_id: photo.id,
action: 'upload',
new_values: { customer_id: clientId, storage_path }
})
return NextResponse.json({
success: true,
data: photo
})
} catch (error) {
console.error('Error in POST /api/aperture/clients/[id]/photos:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,173 @@
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
*/
export async function GET(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
// Get customer basic info
const { data: customer, error: customerError } = await supabaseAdmin
.from('customers')
.select('*')
.eq('id', clientId)
.single()
if (customerError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Get recent bookings
const { data: bookings, error: bookingsError } = await supabaseAdmin
.from('bookings')
.select(`
*,
service:services(name, base_price, duration_minutes),
location:locations(name),
staff:staff(id, first_name, last_name)
`)
.eq('customer_id', clientId)
.order('start_time_utc', { ascending: false })
.limit(20)
if (bookingsError) {
console.error('Error fetching bookings:', bookingsError)
}
// Get loyalty summary
const { data: loyaltyTransactions, error: loyaltyError } = await supabaseAdmin
.from('loyalty_transactions')
.select('*')
.eq('customer_id', clientId)
.order('created_at', { ascending: false })
.limit(10)
if (loyaltyError) {
console.error('Error fetching loyalty transactions:', loyaltyError)
}
// Get photos (if tier allows)
let photos = []
const canAccessPhotos = ['gold', 'black', 'VIP'].includes(customer.tier)
if (canAccessPhotos) {
const { data: photosData, error: photosError } = await supabaseAdmin
.from('customer_photos')
.select('*')
.eq('customer_id', clientId)
.eq('is_active', true)
.order('taken_at', { ascending: false })
.limit(20)
if (!photosError) {
photos = photosData
}
}
// Get subscription (if any)
const { data: subscription, error: subError } = await supabaseAdmin
.from('customer_subscriptions')
.select(`
*,
membership_plan:membership_plans(name, tier, benefits)
`)
.eq('customer_id', clientId)
.eq('status', 'active')
.single()
return NextResponse.json({
success: true,
data: {
customer,
bookings: bookings || [],
loyalty_transactions: loyaltyTransactions || [],
photos,
subscription: subError ? null : subscription
}
})
} catch (error) {
console.error('Error in GET /api/aperture/clients/[id]:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Update client information
* @param {NextRequest} request - Body with updated client data
* @returns {NextResponse} Updated client data
*/
export async function PUT(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
const body = await request.json()
// Get current customer
const { data: currentCustomer, error: fetchError } = await supabaseAdmin
.from('customers')
.select('*')
.eq('id', clientId)
.single()
if (fetchError || !currentCustomer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Update customer
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
.from('customers')
.update({
...body,
updated_at: new Date().toISOString()
})
.eq('id', clientId)
.select()
.single()
if (updateError) {
console.error('Error updating client:', updateError)
return NextResponse.json(
{ success: false, error: updateError.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer',
entity_id: clientId,
action: 'update',
old_values: currentCustomer,
new_values: updatedCustomer
})
return NextResponse.json({
success: true,
data: updatedCustomer
})
} catch (error) {
console.error('Error in PUT /api/aperture/clients/[id]:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,154 @@
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
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const q = searchParams.get('q') || ''
const tier = searchParams.get('tier')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabaseAdmin
.from('customers')
.select(`
*,
bookings:bookings(
id,
short_id,
service_id,
start_time_utc,
status,
total_price
)
`, { count: 'exact' })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
// Apply tier filter
if (tier) {
query = query.eq('tier', tier)
}
// Apply phonetic search if query provided
if (q) {
const searchTerm = `%${q}%`
query = query.or(`first_name.ilike.${searchTerm},last_name.ilike.${searchTerm},email.ilike.${searchTerm},phone.ilike.${searchTerm}`)
}
const { data: customers, error, count } = await query
if (error) {
console.error('Error fetching clients:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch clients' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: customers,
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
})
} catch (error) {
console.error('Error in /api/aperture/clients:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Create new client
* @param {NextRequest} request - Body with client details
* @returns {NextResponse} Created client data
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
first_name,
last_name,
email,
phone,
tier = 'free',
notes,
preferences,
referral_code
} = body
// Validate required fields
if (!first_name || !last_name) {
return NextResponse.json(
{ success: false, error: 'First name and last name are required' },
{ status: 400 }
)
}
// Generate unique referral code if not provided
let finalReferralCode = referral_code
if (!finalReferralCode) {
finalReferralCode = `${first_name.toLowerCase().replace(/[^a-z]/g, '')}${last_name.toLowerCase().replace(/[^a-z]/g, '')}${Date.now().toString(36)}`
}
// Create customer
const { data: customer, error } = await supabaseAdmin
.from('customers')
.insert({
first_name,
last_name,
email: email || null,
phone: phone || null,
tier,
notes,
preferences: preferences || {},
referral_code: finalReferralCode
})
.select()
.single()
if (error) {
console.error('Error creating client:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer',
entity_id: customer.id,
action: 'create',
new_values: {
first_name,
last_name,
email,
tier
}
})
return NextResponse.json({
success: true,
data: customer
})
} catch (error) {
console.error('Error in POST /api/aperture/clients:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,68 @@
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
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabaseAdmin
.from('daily_closing_reports')
.select('*', { count: 'exact' })
.order('report_date', { ascending: false })
.range(offset, offset + limit - 1)
if (location_id) {
query = query.eq('location_id', location_id)
}
if (status) {
query = query.eq('status', status)
}
if (start_date) {
query = query.gte('report_date', start_date)
}
if (end_date) {
query = query.lte('report_date', end_date)
}
const { data: reports, error, count } = await query
if (error) {
console.error('Error fetching daily closing reports:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch daily closing reports' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: reports || [],
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
})
} catch (error) {
console.error('Error in GET /api/aperture/finance/daily-closing:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,143 @@
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
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
location_id,
category,
description,
amount,
expense_date,
payment_method,
receipt_url,
notes
} = body
if (!category || !description || !amount || !expense_date) {
return NextResponse.json(
{ success: false, error: 'category, description, amount, and expense_date are required' },
{ status: 400 }
)
}
const { data: expense, error } = await supabaseAdmin
.from('expenses')
.insert({
location_id,
category,
description,
amount,
expense_date,
payment_method,
receipt_url,
notes,
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
})
.select()
.single()
if (error) {
console.error('Error creating expense:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'expense',
entity_id: expense.id,
action: 'create',
new_values: {
category,
description,
amount
}
})
return NextResponse.json({
success: true,
data: expense
})
} catch (error) {
console.error('Error in POST /api/aperture/finance/expenses:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Get expenses with filters
* @param {NextRequest} request - Query params: location_id, category, start_date, end_date
* @returns {NextResponse} List of expenses
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const category = searchParams.get('category')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabaseAdmin
.from('expenses')
.select('*', { count: 'exact' })
.order('expense_date', { ascending: false })
.range(offset, offset + limit - 1)
if (location_id) {
query = query.eq('location_id', location_id)
}
if (category) {
query = query.eq('category', category)
}
if (start_date) {
query = query.gte('expense_date', start_date)
}
if (end_date) {
query = query.lte('expense_date', end_date)
}
const { data: expenses, error, count } = await query
if (error) {
console.error('Error fetching expenses:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch expenses' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: expenses || [],
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
})
} catch (error) {
console.error('Error in GET /api/aperture/finance/expenses:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get financial summary for date range and location
* @param {NextRequest} request - Query params: location_id, start_date, end_date
* @returns {NextResponse} Financial summary with revenue, expenses, and profit
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
if (!start_date || !end_date) {
return NextResponse.json(
{ success: false, error: 'start_date and end_date are required' },
{ status: 400 }
)
}
// Get financial summary
const { data: summary, error } = await supabaseAdmin.rpc('get_financial_summary', {
p_location_id: location_id || null,
p_start_date: start_date,
p_end_date: end_date
})
if (error) {
console.error('Error fetching financial summary:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch financial summary' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: summary
})
} catch (error) {
console.error('Error in GET /api/aperture/finance:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
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
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
if (!location_id || !start_date || !end_date) {
return NextResponse.json(
{ success: false, error: 'location_id, start_date, and end_date are required' },
{ status: 400 }
)
}
// Get staff performance report
const { data: report, error } = await supabaseAdmin.rpc('get_staff_performance_report', {
p_location_id: location_id,
p_start_date: start_date,
p_end_date: end_date
})
if (error) {
console.error('Error fetching staff performance report:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch staff performance report' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: report
})
} catch (error) {
console.error('Error in GET /api/aperture/finance/staff-performance:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get loyalty history for specific customer
* @param {NextRequest} request - URL params: customerId in path
* @returns {NextResponse} Customer loyalty transactions and history
*/
export async function GET(
request: NextRequest,
{ params }: { params: { customerId: string } }
) {
try {
const { customerId } = params
// Get loyalty summary
const { data: summary, error: summaryError } = await supabaseAdmin
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
if (summaryError) {
console.error('Error fetching loyalty summary:', summaryError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch loyalty summary' },
{ status: 500 }
)
}
// Get loyalty transactions with pagination
const searchParams = request.nextUrl.searchParams
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
const { data: transactions, error: transactionsError, count } = await supabaseAdmin
.from('loyalty_transactions')
.select('*', { count: 'exact' })
.eq('customer_id', customerId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (transactionsError) {
console.error('Error fetching loyalty transactions:', transactionsError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch loyalty transactions' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: {
summary,
transactions: transactions || [],
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
}
})
} catch (error) {
console.error('Error in GET /api/aperture/loyalty/[customerId]:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Add or remove loyalty points for customer
* @param {NextRequest} request - Body with points, transaction_type, description, reference_type, reference_id
* @returns {NextResponse} Transaction result and updated summary
*/
export async function POST(
request: NextRequest,
{ params }: { params: { customerId: string } }
) {
try {
const { customerId } = params
const body = await request.json()
const {
points,
transaction_type = 'admin_adjustment',
description,
reference_type,
reference_id
} = body
if (!points || typeof points !== 'number') {
return NextResponse.json(
{ success: false, error: 'Points amount is required and must be a number' },
{ status: 400 }
)
}
// Add loyalty points
const { data: transactionId, error: error } = await supabaseAdmin
.rpc('add_loyalty_points', {
p_customer_id: customerId,
p_points: points,
p_transaction_type: transaction_type,
p_description: description,
p_reference_type: reference_type,
p_reference_id: reference_id
})
if (error) {
console.error('Error adding loyalty points:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Get updated summary
const { data: summary } = await supabaseAdmin
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
return NextResponse.json({
success: true,
data: {
transaction_id: transactionId,
summary
}
})
} catch (error) {
console.error('Error in POST /api/aperture/loyalty/[customerId]/points:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,92 @@
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
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const customerId = searchParams.get('customerId')
// Get customer ID from auth or query param
let targetCustomerId = customerId
// If no customerId provided, get from authenticated user
if (!targetCustomerId) {
const { data: { user } } = await supabaseAdmin.auth.getUser()
if (!user) {
return NextResponse.json(
{ success: false, error: 'Authentication required' },
{ status: 401 }
)
}
const { data: customer } = await supabaseAdmin
.from('customers')
.select('id')
.eq('user_id', user.id)
.single()
if (!customer) {
return NextResponse.json(
{ success: false, error: 'Customer not found' },
{ status: 404 }
)
}
targetCustomerId = customer.id
}
// Get loyalty summary
const { data: summary, error: summaryError } = await supabaseAdmin
.rpc('get_customer_loyalty_summary', { p_customer_id: targetCustomerId })
if (summaryError) {
console.error('Error fetching loyalty summary:', summaryError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch loyalty summary' },
{ status: 500 }
)
}
// Get recent transactions
const { data: transactions, error: transactionsError } = await supabaseAdmin
.from('loyalty_transactions')
.select('*')
.eq('customer_id', targetCustomerId)
.order('created_at', { ascending: false })
.limit(50)
if (transactionsError) {
console.error('Error fetching loyalty transactions:', transactionsError)
}
// Get available rewards based on points
const { data: membershipPlans, error: plansError } = await supabaseAdmin
.from('membership_plans')
.select('*')
.eq('is_active', true)
if (plansError) {
console.error('Error fetching membership plans:', plansError)
}
return NextResponse.json({
success: true,
data: {
summary,
transactions: transactions || [],
available_rewards: membershipPlans || []
}
})
} catch (error) {
console.error('Error in GET /api/aperture/loyalty:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -6,17 +6,13 @@ import { createClient } from '@supabase/supabase-js';
* @returns Statistics for dashboard display
*/
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://your-project.supabase.co'
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'your-service-role-key-here'
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('Missing Supabase environment variables');
}
const supabase = createClient(supabaseUrl, supabaseServiceKey);
export async function GET() {
try {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://your-project.supabase.co'
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'your-service-role-key-here'
const supabase = createClient(supabaseUrl, supabaseServiceKey);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart);

View File

@@ -2,8 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { supabaseAdmin } from '@/lib/supabase/admin'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
/**
* @description Creates a Stripe payment intent for booking deposit (50% of service price, max $200)
* @param {NextRequest} request - Request containing booking details
@@ -11,6 +9,14 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
*/
export async function POST(request: NextRequest) {
try {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
if (!stripeSecretKey) {
return NextResponse.json({ error: 'Stripe not configured' }, { status: 500 })
}
const stripe = new Stripe(stripeSecretKey)
const {
customer_email,
customer_phone,

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description CRITICAL: Detect and mark no-show bookings (runs every 2 hours)
* @param {NextRequest} request - Must include Bearer token with CRON_SECRET
* @returns {NextResponse} No-show detection results with count of bookings processed
* @example curl -H "Authorization: Bearer YOUR_CRON_SECRET" /api/cron/detect-no-shows
* @audit BUSINESS RULE: No-show window is 12 hours after booking start time (UTC)
* @audit SECURITY: Requires CRON_SECRET environment variable for authentication
* @audit Validate: Only confirmed/pending bookings without check-in are affected
* @audit AUDIT: Detection action logged in audit_logs with booking details
* @audit PERFORMANCE: Efficient query with date range and status filters
* @audit RELIABILITY: Cron job should run every 2 hours to detect no-shows
*/
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
const cronKey = authHeader.replace('Bearer ', '').trim()
if (cronKey !== process.env.CRON_SECRET) {
return NextResponse.json(
{ success: false, error: 'Invalid cron key' },
{ status: 403 }
)
}
// Calculate no-show window: bookings that started more than 12 hours ago
const windowStart = new Date()
windowStart.setHours(windowStart.getHours() - 12)
// Get eligible bookings (confirmed/pending, no check-in, started > 12h ago)
const { data: bookings, error: bookingsError } = await supabaseAdmin
.from('bookings')
.select('id, start_time_utc, customer_id, service_id, deposit_amount')
.in('status', ['confirmed', 'pending'])
.lt('start_time_utc', windowStart.toISOString())
.is('check_in_time', null)
if (bookingsError) {
console.error('Error fetching bookings for no-show detection:', bookingsError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch bookings' },
{ status: 500 }
)
}
if (!bookings || bookings.length === 0) {
return NextResponse.json({
success: true,
message: 'No bookings to process',
processedCount: 0,
detectedCount: 0
})
}
let detectedCount = 0
// Process each booking
for (const booking of bookings) {
const detected = await supabaseAdmin.rpc('detect_no_show_booking', {
p_booking_id: booking.id
})
if (detected) {
detectedCount++
}
}
console.log(`No-show detection completed: ${detectedCount} bookings detected out of ${bookings.length} processed`)
return NextResponse.json({
success: true,
message: 'No-show detection completed successfully',
processedCount: bookings.length,
detectedCount
})
} catch (error) {
console.error('Error in no-show detection:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -14,17 +14,20 @@ import { createClient } from '@supabase/supabase-js'
* @audit RELIABILITY: Cron job should run exactly at Monday 00:00 UTC weekly
*/
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('Missing Supabase environment variables')
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
export async function GET(request: NextRequest) {
try {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ success: false, error: 'Missing Supabase environment variables' },
{ status: 500 }
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server'
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
*/
export async function POST(request: NextRequest) {
try {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET
if (!stripeSecretKey || !stripeWebhookSecret) {
return NextResponse.json(
{ error: 'Stripe not configured' },
{ status: 500 }
)
}
const stripe = new Stripe(stripeSecretKey)
const body = await request.text()
const signature = request.headers.get('stripe-signature')
if (!signature) {
return NextResponse.json(
{ error: 'Missing Stripe signature' },
{ status: 400 }
)
}
// Verify webhook signature
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
stripeWebhookSecret
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
)
}
const eventId = event.id
// Check if event already processed
const { data: existingLog } = await supabaseAdmin
.from('webhook_logs')
.select('*')
.eq('event_id', eventId)
.single()
if (existingLog) {
console.log(`Event ${eventId} already processed, skipping`)
return NextResponse.json({ received: true, already_processed: true })
}
// Log webhook event
await supabaseAdmin.from('webhook_logs').insert({
event_type: event.type,
event_id: eventId,
payload: event.data as any
})
// Process based on event type
switch (event.type) {
case 'payment_intent.succeeded':
await supabaseAdmin.rpc('process_payment_intent_succeeded', {
p_event_id: eventId,
p_payload: event.data as any
})
break
case 'payment_intent.payment_failed':
await supabaseAdmin.rpc('process_payment_intent_failed', {
p_event_id: eventId,
p_payload: event.data as any
})
break
case 'charge.refunded':
await supabaseAdmin.rpc('process_charge_refunded', {
p_event_id: eventId,
p_payload: event.data as any
})
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Error processing Stripe webhook:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
}

287
app/testlinks/page.tsx Normal file
View File

@@ -0,0 +1,287 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* @description Test links page - Access to all AnchorOS pages and API endpoints
* @param {NextRequest} request
* @returns {NextResponse} HTML page with links to all pages and APIs
*/
export async function GET(request: NextRequest) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:2311'
const pages = [
// anchor23.mx - Frontend Institucional
{ name: 'Home (Landing)', url: '/' },
{ name: 'Servicios', url: '/servicios' },
{ name: 'Historia', url: '/historia' },
{ name: 'Contacto', url: '/contacto' },
{ name: 'Franquicias', url: '/franchises' },
{ name: 'Membresías', url: '/membresias' },
{ name: 'Privacy Policy', url: '/privacy-policy' },
{ name: 'Legal', url: '/legal' },
// booking.anchor23.mx - The Boutique (Frontend de Reservas)
{ name: 'Booking - Servicios', url: '/booking/servicios' },
{ name: 'Booking - Cita', url: '/booking/cita' },
{ name: 'Booking - Confirmación', url: '/booking/confirmacion' },
{ name: 'Booking - Registro', url: '/booking/registro' },
{ name: 'Booking - Login', url: '/booking/login' },
{ name: 'Booking - Perfil', url: '/booking/perfil' },
{ name: 'Booking - Mis Citas', url: '/booking/mis-citas' },
// aperture.anchor23.mx - Dashboard Administrativo
{ name: 'Aperture - Login', url: '/aperture/login' },
{ name: 'Aperture - Dashboard', url: '/aperture' },
{ name: 'Aperture - Calendario', url: '/aperture/calendar' },
// kiosk.anchor23.mx - Sistema de Autoservicio
{ name: 'Kiosk - [locationId]', url: '/kiosk/LOCATION_ID_HERE' },
// Admin & Enrollment
{ name: 'HQ Dashboard (Antiguo)', url: '/hq' },
{ name: 'Admin Enrollment', url: '/admin/enrollment' },
]
const apis = [
// APIs Públicas
{ name: 'Services', url: '/api/services', method: 'GET' },
{ name: 'Locations', url: '/api/locations', method: 'GET' },
{ name: 'Customers (List)', url: '/api/customers', method: 'GET' },
{ name: 'Customers (Create)', url: '/api/customers', method: 'POST' },
{ name: 'Availability', url: '/api/availability', method: 'GET' },
{ name: 'Availability Time Slots', url: '/api/availability/time-slots', method: 'GET' },
{ name: 'Public Availability', url: '/api/public/availability', method: 'GET' },
{ name: 'Availability Blocks', url: '/api/availability/blocks', method: 'GET' },
{ name: 'Bookings (List)', url: '/api/bookings', method: 'GET' },
{ name: 'Bookings (Create)', url: '/api/bookings', method: 'POST' },
// Kiosk APIs
{ name: 'Kiosk - Authenticate', url: '/api/kiosk/authenticate', method: 'POST' },
{ name: 'Kiosk - Available Resources', url: '/api/kiosk/resources/available', method: 'GET' },
{ name: 'Kiosk - Bookings', url: '/api/kiosk/bookings', method: 'POST' },
{ name: 'Kiosk - Walkin', url: '/api/kiosk/walkin', method: 'POST' },
// Payment APIs
{ name: 'Create Payment Intent', url: '/api/create-payment-intent', method: 'POST' },
// Aperture APIs
{ name: 'Aperture - Dashboard', url: '/api/aperture/dashboard', method: 'GET' },
{ name: 'Aperture - Stats', url: '/api/aperture/stats', method: 'GET' },
{ name: 'Aperture - Calendar', url: '/api/aperture/calendar', method: 'GET' },
{ name: 'Aperture - Staff (List)', url: '/api/aperture/staff', method: 'GET' },
{ name: 'Aperture - Staff (Create)', url: '/api/aperture/staff', method: 'POST' },
{ name: 'Aperture - Resources', url: '/api/aperture/resources', method: 'GET' },
{ name: 'Aperture - Payroll', url: '/api/aperture/payroll', method: 'GET' },
{ name: 'Aperture - POS', url: '/api/aperture/pos', method: 'POST' },
{ name: 'Aperture - Close Day', url: '/api/aperture/pos/close-day', method: 'POST' },
// Client Management (FASE 5)
{ name: 'Aperture - Clients (List)', url: '/api/aperture/clients', method: 'GET' },
{ name: 'Aperture - Clients (Create)', url: '/api/aperture/clients', method: 'POST' },
{ name: 'Aperture - Client Details', url: '/api/aperture/clients/[id]', method: 'GET' },
{ name: 'Aperture - Client Notes', url: '/api/aperture/clients/[id]/notes', method: 'POST' },
{ name: 'Aperture - Client Photos', url: '/api/aperture/clients/[id]/photos', method: 'GET' },
// Loyalty System (FASE 5)
{ name: 'Aperture - Loyalty', url: '/api/aperture/loyalty', method: 'GET' },
{ name: 'Aperture - Loyalty History', url: '/api/aperture/loyalty/[customerId]', method: 'GET' },
// Webhooks (FASE 6)
{ name: 'Stripe Webhooks', url: '/api/webhooks/stripe', method: 'POST' },
// Cron Jobs (FASE 6)
{ name: 'Reset Invitations (Cron)', url: '/api/cron/reset-invitations', method: 'GET' },
{ name: 'Detect No-Shows (Cron)', url: '/api/cron/detect-no-shows', method: 'GET' },
// Bookings Actions (FASE 6)
{ name: 'Bookings - Check-in', url: '/api/aperture/bookings/check-in', method: 'POST' },
{ name: 'Bookings - No-Show', url: '/api/aperture/bookings/no-show', method: 'POST' },
// Finance (FASE 6)
{ name: 'Aperture - Finance Summary', url: '/api/aperture/finance', method: 'GET' },
{ name: 'Aperture - Daily Closing', url: '/api/aperture/finance/daily-closing', method: 'GET' },
{ name: 'Aperture - Expenses (List)', url: '/api/aperture/finance/expenses', method: 'GET' },
{ name: 'Aperture - Expenses (Create)', url: '/api/aperture/finance/expenses', method: 'POST' },
{ name: 'Aperture - Staff Performance', url: '/api/aperture/finance/staff-performance', method: 'GET' },
]
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AnchorOS - Test Links</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
}
.content {
padding: 40px;
}
.section {
margin-bottom: 40px;
}
.section h2 {
color: #667eea;
font-size: 1.8em;
margin-bottom: 20px;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
}
.card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 15px;
transition: all 0.3s ease;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
border-color: #667eea;
}
.card h3 {
color: #333;
font-size: 1.1em;
margin-bottom: 10px;
}
.card a {
display: block;
color: #667eea;
text-decoration: none;
font-size: 0.9em;
word-break: break-all;
}
.card a:hover {
text-decoration: underline;
}
.method {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.75em;
font-weight: bold;
margin-bottom: 5px;
}
.get { background: #28a745; color: white; }
.post { background: #007bff; color: white; }
.put { background: #ffc107; color: #333; }
.delete { background: #dc3545; color: white; }
.badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7em;
font-weight: bold;
margin-left: 5px;
}
.phase-5 { background: #ff9800; color: white; }
.phase-6 { background: #9c27b0; color: white; }
.footer {
background: #f8f9fa;
padding: 20px;
text-align: center;
color: #666;
border-top: 1px solid #e9ecef;
}
.info {
background: #e7f3ff;
border-left: 4px solid #007bff;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.info strong {
color: #007bff;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🥂 AnchorOS - Test Links</h1>
<p>Complete directory of all pages and API endpoints</p>
</div>
<div class="content">
<div class="info">
<strong>⚠️ Note:</strong> Replace <code>LOCATION_ID_HERE</code> with actual UUID from your database.
For cron jobs, use: <code>curl -H "Authorization: Bearer YOUR_CRON_SECRET"</code>
</div>
<div class="section">
<h2>📄 Pages</h2>
<div class="grid">
${pages.map(page => `
<div class="card">
<h3>${page.name}</h3>
<a href="${baseUrl}${page.url}" target="_blank">${baseUrl}${page.url}</a>
</div>
`).join('')}
</div>
</div>
<div class="section">
<h2>🔌 API Endpoints</h2>
<div class="grid">
${apis.map(api => `
<div class="card">
<div>
<span class="method ${api.method.toLowerCase()}">${api.method}</span>
${api.name.includes('FASE') ? `<span class="badge ${api.name.includes('FASE 5') ? 'phase-5' : 'phase-6'}">${api.name.match(/FASE \d+/)[0]}</span>` : ''}
</div>
<h3>${api.name}</h3>
<a href="${baseUrl}${api.url}" target="_blank">${baseUrl}${api.url}</a>
</div>
`).join('')}
</div>
</div>
</div>
<div class="footer">
<p>AnchorOS - Codename: Adela | Last updated: ${new Date().toISOString()}</p>
</div>
</div>
</body>
</html>
`
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
})
}

View File

@@ -18,8 +18,8 @@ export default function DatePicker({ selectedDate, onDateSelect, minDate, disabl
end: endOfMonth(currentMonth)
})
const previousMonth = () => setCurrentMonth(subMonths(currentMonth, 1))
const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1))
const previousMonth = () => setCurrentMonth(subMonths(currentMonth,1))
const nextMonth = () => setCurrentMonth(addMonths(currentMonth,1))
const isDateDisabled = (date: Date) => {
if (minDate) {
@@ -32,6 +32,112 @@ export default function DatePicker({ selectedDate, onDateSelect, minDate, disabl
return selectedDate && isSameDay(date, selectedDate)
}
// Calcular el offset del primer día del mes
// getDay() devuelve: 0=Domingo, 1=Lunes, 2=Martes, ..., 6=Sábado
// Para calendario que empieza en Lunes, necesitamos ajustar:
// Si getDay() = 0 (Domingo), offset = 6
// Si getDay() = 1-6 (Lunes-Sábado), offset = getDay() - 1
const firstDayOfMonth = startOfMonth(currentMonth)
const dayOfWeek = firstDayOfMonth.getDay()
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1
// Crear array con celdas vacías al inicio para el padding
const paddingDays = Array.from({ length: offset }, (_, i) => ({ day: null, key: `padding-${i}` }))
// Crear array de días con key único
const calendarDays = days.map((date, i) => ({ day: date, key: `day-${i}` }))
// Combinar padding + días del mes
const allDays = [...paddingDays, ...calendarDays]
return (
<div className="w-full">
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={previousMonth}
disabled={disabled}
className="p-2 hover:bg-[var(--mocha-taupe)]/10 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" style={{ color: 'var(--charcoal-brown)' }} />
</button>
<h3 className="text-lg font-medium" style={{ color: 'var(--charcoal-brown)' }}>
{format(currentMonth, 'MMMM yyyy', { locale: es })}
</h3>
<button
type="button"
onClick={nextMonth}
disabled={disabled}
className="p-2 hover:bg-[var(--mocha-taupe)]/10 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-5 h-5" style={{ color: 'var(--charcoal-brown)' }} />
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((day, index) => (
<div
key={index}
className="text-center text-sm font-medium py-2"
style={{ color: 'var(--mocha-taupe)' }}
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{allDays.map(({ day, key }) => {
// Si es celda de padding (day es null)
if (!day) {
return (
<div
key={key}
className="p-2"
/>
)
}
const disabled = isDateDisabled(day)
const selected = isDateSelected(day)
const today = isToday(day)
const notCurrentMonth = !isSameMonth(day, currentMonth)
return (
<button
key={key}
type="button"
onClick={() => !disabled && !notCurrentMonth && onDateSelect(day)}
disabled={disabled || notCurrentMonth}
className={`
relative p-2 text-sm font-medium rounded-md transition-all
${disabled || notCurrentMonth ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer hover:bg-[var(--mocha-taupe)]/10'}
${selected ? 'text-white' : ''}
${today && !selected ? 'font-bold' : ''}
`}
style={selected ? { background: 'var(--deep-earth)' } : { color: 'var(--charcoal-brown)' }}
>
{format(day, 'd')}
{today && !selected && (
<span
className="absolute bottom-1 left-1/2 transform -translate-x-1/2 w-1 h-1 rounded-full"
style={{ background: 'var(--deep-earth)' }}
/>
)}
</button>
)
})}
</div>
</div>
)
}
return false
}
const isDateSelected = (date: Date) => {
return selectedDate && isSameDay(date, selectedDate)
}
return (
<div className="w-full">
<div className="flex items-center justify-between mb-4">

792
docs/APERATURE_SPECS.md Normal file
View File

@@ -0,0 +1,792 @@
# Aperture Technical Specifications
**Documento maestro de especificaciones técnicas de Aperture (HQ Dashboard)**
**Última actualización: Enero 2026**
---
## 1. Arquitectura General
### 1.1 Stack Tecnológico
**Frontend:**
- Next.js 14 (App Router)
- React 18
- TypeScript 5.x
- Tailwind CSS + Radix UI
- Lucide React (icons)
- date-fns (manejo de fechas)
**Backend:**
- Next.js API Routes
- Supabase PostgreSQL
- Supabase Auth (roles: admin, manager, staff, customer, kiosk, artist)
- Stripe (pagos)
**Infraestructura:**
- Vercel (hosting)
- Supabase (database, auth, storage)
- Vercel Cron Jobs (tareas programadas)
---
## 2. Esquema de Base de Datos
### 2.1 Tablas Core
```sql
-- Locations (sucursales)
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
address TEXT NOT NULL,
phone TEXT,
timezone TEXT NOT NULL DEFAULT 'America/Mexico_City',
business_hours JSONB NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Staff (empleados)
CREATE TABLE staff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
phone TEXT,
role TEXT NOT NULL CHECK (role IN ('admin', 'manager', 'staff', 'artist')),
location_id UUID REFERENCES locations(id),
hourly_rate DECIMAL(10,2) DEFAULT 0,
commission_rate DECIMAL(5,2) DEFAULT 0, -- Porcentaje de comisión
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Resources (recursos físicos)
CREATE TABLE resources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, -- Código estandarizado: mkup-1, lshs-1, pedi-1, mani-1
type TEXT NOT NULL CHECK (type IN ('mkup', 'lshs', 'pedi', 'mani')),
location_id UUID REFERENCES locations(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Services (servicios)
CREATE TABLE services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
base_price DECIMAL(10,2) NOT NULL,
duration_minutes INTEGER NOT NULL,
requires_dual_artist BOOLEAN DEFAULT false,
premium_fee DECIMAL(10,2) DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Customers (clientes)
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE,
phone TEXT,
first_name TEXT NOT NULL,
last_name TEXT,
tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'gold', 'black', 'VIP')),
weekly_invitations_used INTEGER DEFAULT 0,
referral_code TEXT UNIQUE,
referred_by UUID REFERENCES customers(id),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Bookings (reservas)
CREATE TABLE bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
short_id TEXT UNIQUE NOT NULL,
customer_id UUID REFERENCES customers(id),
service_id UUID REFERENCES services(id),
location_id UUID REFERENCES locations(id),
staff_ids UUID[] NOT NULL, -- Array de staff IDs (1 o 2 para dual artist)
resource_id UUID REFERENCES resources(id),
start_time_utc TIMESTAMPTZ NOT NULL,
end_time_utc TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'completed', 'cancelled', 'no_show')),
deposit_amount DECIMAL(10,2) DEFAULT 0,
deposit_paid BOOLEAN DEFAULT false,
total_price DECIMAL(10,2),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Payments (pagos)
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID REFERENCES bookings(id),
amount DECIMAL(10,2) NOT NULL,
payment_method TEXT NOT NULL CHECK (payment_method IN ('cash', 'card', 'transfer', 'gift_card', 'membership', 'stripe')),
stripe_payment_intent_id TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'refunded', 'failed')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Payroll (nómina)
CREATE TABLE payroll (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
staff_id UUID REFERENCES staff(id),
period_start DATE NOT NULL,
period_end DATE NOT NULL,
base_salary DECIMAL(10,2) DEFAULT 0,
commission_total DECIMAL(10,2) DEFAULT 0,
tips_total DECIMAL(10,2) DEFAULT 0,
total_payment DECIMAL(10,2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'cancelled')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Audit Logs (auditoría)
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID,
action TEXT NOT NULL,
old_values JSONB,
new_values JSONB,
performed_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
---
## 3. APIs Principales
### 3.1 Dashboard Stats
**Endpoint:** `GET /api/aperture/stats`
**Response:**
```typescript
{
success: true,
stats: {
totalBookings: number, // Reservas del mes actual
totalRevenue: number, // Revenue del mes (servicios completados)
completedToday: number, // Citas completadas hoy
upcomingToday: number // Citas pendientes hoy
}
}
```
**Business Rules:**
- Month calculations: first day to last day of current month (UTC)
- Today calculations: 00:00 to 23:59:59.999 local timezone converted to UTC
- Revenue only includes `status = 'completed'` bookings
---
### 3.2 Dashboard Data
**Endpoint:** `GET /api/aperture/dashboard`
**Response:**
```typescript
{
success: true,
data: {
customers: {
total: number,
newToday: number,
newMonth: number
},
topPerformers: Array<{
id: string,
name: string,
bookingsCompleted: number,
revenueGenerated: number
}>,
activityFeed: Array<{
id: string,
type: 'booking' | 'payment' | 'staff' | 'system',
description: string,
timestamp: string,
metadata?: any
}>
}
}
```
---
### 3.3 Calendar API
**Endpoint:** `GET /api/aperture/calendar`
**Query Params:**
- `date`: YYYY-MM-DD (default: today)
- `location_id`: UUID (optional, filter by location)
- `staff_ids`: UUID[] (optional, filter by staff)
**Response:**
```typescript
{
success: true,
data: {
date: string,
slots: Array<{
time: string, // HH:mm format
bookings: Array<{
id: string,
short_id: string,
customer_name: string,
service_name: string,
staff_ids: string[],
staff_names: string[],
resource_id: string,
status: string,
duration: number,
requires_dual_artist: boolean,
start_time: string,
end_time: string,
notes?: string
}>
}>
},
staff: Array<{
id: string,
name: string,
role: string,
bookings_count: number
}>
}
```
---
### 3.4 Reschedule Booking
**Endpoint:** `POST /api/aperture/bookings/[id]/reschedule`
**Request:**
```typescript
{
new_start_time_utc: string, // ISO 8601 timestamp
new_resource_id?: string // Optional new resource
}
```
**Response:**
```typescript
{
success: boolean,
message?: string,
conflict?: {
type: 'staff' | 'resource',
message: string,
details: any
}
}
```
**Validation:**
- Check staff availability for new time
- Check resource availability for new time
- Verify no conflicts with existing bookings
- Update booking if no conflicts
---
### 3.5 Staff Management
**CRUD Endpoints:**
- `GET /api/aperture/staff` - List all staff
- `GET /api/aperture/staff/[id]` - Get single staff
- `POST /api/aperture/staff` - Create staff
- `PUT /api/aperture/staff/[id]` - Update staff
- `DELETE /api/aperture/staff/[id]` - Delete staff
**Staff Object:**
```typescript
{
id: string,
first_name: string,
last_name: string,
email: string,
phone?: string,
role: 'admin' | 'manager' | 'staff' | 'artist',
location_id?: string,
hourly_rate: number,
commission_rate: number,
is_active: boolean,
business_hours?: {
monday: { start: string, end: string, is_off: boolean },
tuesday: { start: string, end: string, is_off: boolean },
// ... other days
}
}
```
---
### 3.6 Payroll Calculation
**Endpoint:** `GET /api/aperture/payroll`
**Query Params:**
- `period_start`: YYYY-MM-DD
- `period_end`: YYYY-MM-DD
- `staff_id`: UUID (optional)
**Response:**
```typescript
{
success: true,
data: {
staff_payroll: Array<{
staff_id: string,
staff_name: string,
base_salary: number, // hourly_rate * hours_worked
commission_total: number, // revenue * commission_rate
tips_total: number, // Sum of tips
total_payment: number, // Sum of above
bookings_count: number,
hours_worked: number
}>,
summary: {
total_payroll: number,
total_bookings: number,
period: {
start: string,
end: string
}
}
}
}
```
**Calculation Logic:**
```
base_salary = hourly_rate * sum(booking duration / 60)
commission_total = total_revenue * (commission_rate / 100)
tips_total = sum(tips from completed bookings)
total_payment = base_salary + commission_total + tips_total
```
---
### 3.7 POS (Point of Sale)
**Endpoint:** `POST /api/aperture/pos`
**Request:**
```typescript
{
items: Array<{
type: 'service' | 'product',
id: string,
name: string,
price: number,
quantity: number
}>,
payments: Array<{
method: 'cash' | 'card' | 'transfer' | 'gift_card' | 'membership',
amount: number,
stripe_payment_intent_id?: string
}>,
customer_id?: string,
booking_id?: string,
notes?: string
}
```
**Response:**
```typescript
{
success: boolean,
transaction_id: string,
total_amount: number,
change?: number, // For cash payments
receipt_url?: string
}
```
---
### 3.8 Close Day
**Endpoint:** `POST /api/aperture/pos/close-day`
**Request:**
```typescript
{
date: string, // YYYY-MM-DD
location_id?: string
}
```
**Response:**
```typescript
{
success: true,
summary: {
date: string,
location_id?: string,
total_sales: number,
payment_breakdown: {
cash: number,
card: number,
transfer: number,
gift_card: number,
membership: number,
stripe: number
},
transaction_count: number,
refunds: number,
discrepancies: Array<{
type: string,
expected: number,
actual: number,
difference: number
}>
},
pdf_url: string
}
```
---
## 4. Horas Trabajadas (Automático desde Bookings)
### 4.1 Cálculo Automático
Las horas trabajadas por staff se calculan automáticamente desde bookings completados:
```typescript
async function getStaffWorkHours(staffId: string, periodStart: Date, periodEnd: Date) {
const { data: bookings } = await supabase
.from('bookings')
.select('start_time_utc, end_time_utc')
.contains('staff_ids', [staffId])
.eq('status', 'completed')
.gte('start_time_utc', periodStart.toISOString())
.lte('start_time_utc', periodEnd.toISOString());
const totalMinutes = bookings.reduce((sum, booking) => {
const start = new Date(booking.start_time_utc);
const end = new Date(booking.end_time_utc);
return sum + (end.getTime() - start.getTime()) / 60000;
}, 0);
return totalMinutes / 60; // Return hours
}
```
### 4.2 Integración con Nómina
El cálculo de nómina utiliza estas horas automáticamente:
```typescript
base_salary = staff.hourly_rate * work_hours
commission = total_revenue * (staff.commission_rate / 100)
```
---
## 5. POS System Specifications
### 5.1 Características Principales
**Carrito de Compra:**
- Soporte para múltiples productos/servicios
- Cantidad por item
- Descuentos aplicables
- Subtotal, taxes (si aplica), total
**Métodos de Pago:**
- Efectivo (con cálculo de cambio)
- Tarjeta (Stripe)
- Transferencia bancaria
- Gift Cards
- Membresías (créditos del cliente)
- Pagos mixtos (combinar múltiples métodos)
**Múltiples Cajeros:**
- Each staff can open a POS session
- Track cashier per transaction
- Close day per cashier or per location
### 5.2 Flujo de Cierre de Caja
1. Solicitar fecha y location_id
2. Calcular total ventas del día
3. Breakdown por método de pago
4. Verificar conciliación (esperado vs real)
5. Generar PDF reporte
6. Marcar day como "closed" (opcional flag)
---
## 6. Webhooks Stripe
### 6.1 Endpoints
**Endpoint:** `POST /api/webhooks/stripe`
**Headers:**
- `Stripe-Signature`: Signature verification
**Events:**
- `payment_intent.succeeded`: Payment completed
- `payment_intent.payment_failed`: Payment failed
- `charge.refunded`: Refund processed
### 6.2 payment_intent.succeeded
**Actions:**
1. Extract metadata (booking details)
2. Verify booking exists
3. Update `payments` table with completed status
4. Update booking `deposit_paid = true`
5. Create audit log entry
6. Send confirmation email/WhatsApp (si configurado)
### 6.3 payment_intent.payment_failed
**Actions:**
1. Update `payments` table with failed status
2. Send notification to customer
3. Log failure in audit logs
4. Optionally cancel booking or mark as pending
### 6.4 charge.refunded
**Actions:**
1. Update `payments` table with refunded status
2. Send refund confirmation to customer
3. Log refund in audit logs
4. Update booking status if applicable
---
## 7. No-Show Logic
### 7.1 Ventana de Cancelación
**Regla:** 12 horas antes de la cita (UTC)
### 7.2 Detección de No-Show
```typescript
async function detectNoShows() {
const now = new Date();
const windowStart = new Date(now.getTime() - 12 * 60 * 60 * 1000); // 12h ago
const { data: noShows } = await supabase
.from('bookings')
.select('*')
.eq('status', 'confirmed')
.lte('start_time_utc', windowStart.toISOString());
for (const booking of noShows) {
// Check if customer showed up
const { data: checkIn } = await supabase
.from('check_ins')
.select('*')
.eq('booking_id', booking.id)
.single();
if (!checkIn) {
// Mark as no-show
await markAsNoShow(booking.id);
}
}
}
```
### 7.3 Penalización Automática
**Actions:**
1. Mark booking status as `no_show`
2. Retain deposit (do not refund)
3. Send notification to customer
4. Log action in audit_logs
5. Track no-show count per customer (for future restrictions)
### 7.4 Override Admin
Admin puede marcar un no-show como "exonerated" (perdonado):
- Status remains `no_show` but with flag `penalty_waived = true`
- Refund deposit if appropriate
- Log admin override in audit logs
---
## 8. Seguridad y Permisos
### 8.1 RLS Policies
**Admin:**
- Full access to all tables
- Can override no-show penalties
- Can view all financial data
**Manager:**
- Access to location data only
- Can manage staff and bookings
- View financial reports for location
**Staff/Artist:**
- View own bookings and schedule
- Cannot view customer PII (email, phone)
- Cannot modify financial data
**Kiosk:**
- View only availability data
- Can create bookings with validated data
- No access to PII
### 8.2 API Authentication
**Admin/Manager/Staff:**
- Require valid Supabase session
- Check user role
- Filter by location for managers
**Public:**
- Use anon key
- Only public endpoints (availability, services, locations)
**Cron Jobs:**
- Require CRON_SECRET header
- Service role key required
---
## 9. Performance Considerations
### 9.1 Database Indexes
```sql
-- Critical indexes
CREATE INDEX idx_bookings_customer ON bookings(customer_id);
CREATE INDEX idx_bookings_staff ON bookings USING GIN(staff_ids);
CREATE INDEX idx_bookings_status_time ON bookings(status, start_time_utc);
CREATE INDEX idx_payments_booking ON payments(booking_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
```
### 9.2 N+1 Prevention
Use explicit joins for related data:
```typescript
// BAD - N+1 queries
const bookings = await supabase.from('bookings').select('*');
for (const booking of bookings) {
const customer = await supabase.from('customers').select('*').eq('id', booking.customer_id);
}
// GOOD - Single query
const bookings = await supabase
.from('bookings')
.select(`
*,
customer:customers(*),
service:services(*),
location:locations(*)
`);
```
---
## 10. Testing Strategy
### 10.1 Unit Tests
- Generador de Short ID (collision detection)
- Cálculo de depósitos (200 vs 50% rule)
- Cálculo de nómina (salario base + comisiones + propinas)
- Disponibilidad de staff (horarios + calendar events)
### 10.2 Integration Tests
- API endpoints (GET, POST, PUT, DELETE)
- Stripe webhooks
- Cron jobs (reset invitations)
- No-show detection
### 10.3 E2E Tests
- Booking flow completo (customer → kiosk → staff)
- POS flow (items → payment → receipt)
- Dashboard navigation y visualización
- Calendar drag & drop
---
## 11. Deployment
### 11.1 Environment Variables
```env
# Supabase
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Stripe
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Cron
CRON_SECRET=
# Email/WhatsApp (future)
RESEND_API_KEY=
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
```
### 11.2 Cron Jobs
```yaml
# vercel.json
{
"crons": [
{
"path": "/api/cron/reset-invitations",
"schedule": "0 0 * * 1" # Monday 00:00 UTC
},
{
"path": "/api/cron/detect-no-shows",
"schedule": "0 */2 * * *" # Every 2 hours
}
]
}
```
---
## 12. Futuras Mejoras
### 12.1 Short Term (Q1 2026)
- [ ] Implementar The Vault (storage de fotos privadas)
- [ ] Implementar notificaciones WhatsApp
- [ ] Implementar recibos digitales con PDF
- [ ] Landing page Believers pública
### 12.2 Medium Term (Q2 2026)
- [ ] Google Calendar Sync bidireccional
- [ ] Sistema de lealtad con puntos
- [ ] Campañas de marketing masivas
- [ ] Precios dinámicos inteligentes
### 12.3 Long Term (Q3-Q4 2026)
- [ ] Sistema de passes digitales
- [ ] Móvil app para clientes
- [ ] Analytics avanzados con ML
- [ ] Integración con POS hardware

View File

@@ -662,7 +662,416 @@ Antes de considerar un componente como "completado":
---
## 21. Changelog
## 21. Ejemplos de Uso de Radix UI con Square UI Styling
### 21.1 Button Component (Radix UI)
```typescript
// components/ui/button.tsx
'use client'
import * as React from 'react'
import * as ButtonPrimitive from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ease-out focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-[#006AFF] text-white hover:bg-[#005ED6] active:translate-y-0',
secondary: 'bg-white text-[#24292E] border border-[#E1E4E8] hover:bg-[#F3F4F6]',
ghost: 'text-[#24292E] hover:bg-[#F3F4F6]',
danger: 'bg-[#D73A49] text-white hover:bg-[#B91C3C]',
success: 'bg-[#28A745] text-white hover:bg-[#218838]',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
```
**Uso:**
```typescript
<Button variant="default" size="md">
Save Changes
</Button>
<Button variant="secondary" size="sm">
Cancel
</Button>
<Button variant="danger" size="lg">
Delete
</Button>
```
---
### 21.2 Dialog Component (Radix UI)
```typescript
// components/ui/dialog.tsx
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[#E1E4E8] bg-white p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-xl"
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[#006AFF] focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-gray-100">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className="flex flex-col space-y-1.5 text-center sm:text-left" {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className="text-lg font-semibold leading-none tracking-tight"
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogClose }
```
**Uso:**
```typescript
<Dialog>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Action</DialogTitle>
</DialogHeader>
<p>Are you sure you want to proceed?</p>
<div className="flex gap-2 justify-end">
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button variant="danger">Confirm</Button>
</div>
</DialogContent>
</Dialog>
```
---
### 21.3 Select Component (Radix UI)
```typescript
// components/ui/select.tsx
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown } from 'lucide-react'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className="flex h-10 w-full items-center justify-between rounded-lg border border-[#E1E4E8] bg-white px-3 py-2 text-sm placeholder:text-[#8B949E] focus:outline-none focus:ring-2 focus:ring-[#006AFF] focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1"
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className="relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-[#E1E4E8] bg-white text-[#24292E] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
position={position}
{...props}
>
<SelectPrimitive.Viewport className="p-1">
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-[#F3F4F6] focus:text-[#24292E] data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
```
**Uso:**
```typescript
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectContent>
</Select>
```
---
### 21.4 Tabs Component (Radix UI)
```typescript
// components/ui/tabs.tsx
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className="inline-flex h-10 items-center justify-center rounded-lg bg-[#F6F8FA] p-1 text-[#586069]"
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#006AFF] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-[#24292E] data-[state=active]:shadow-sm"
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className="mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#006AFF] focus-visible:ring-offset-2"
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
```
**Uso:**
```typescript
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div>Account settings...</div>
</TabsContent>
<TabsContent value="password">
<div>Password settings...</div>
</TabsContent>
</Tabs>
```
---
### 21.5 Accesibilidad con Radix UI
**ARIA Attributes Automáticos:**
```typescript
// Radix UI agrega automáticamente:
// - role="button" para botones
// - aria-expanded para dropdowns
// - aria-selected para tabs
// - aria-checked para checkboxes
// - aria-invalid para inputs con error
// - aria-describedby para errores de formulario
// Ejemplo con manejo de errores:
<Select>
<SelectTrigger aria-invalid={hasError} aria-describedby={errorMessage ? 'error-message' : undefined}>
<SelectValue />
</SelectTrigger>
{errorMessage && (
<p id="error-message" className="text-sm text-[#D73A49]">
{errorMessage}
</p>
)}
</Select>
```
**Keyboard Navigation:**
```typescript
// Radix UI soporta automáticamente:
// - Tab: Navigate focusable elements
// - Enter/Space: Activate buttons, select options
// - Escape: Close modals, dropdowns
// - Arrow keys: Navigate within components (lists, menus)
// - Home/End: Jump to start/end of list
// Para keyboard shortcuts personalizados:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
// Open search modal
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
```
---
## 22. Guía de Migración a Radix UI
### 22.1 Componentes que Migrar
**De Headless UI a Radix UI:**
- `<Dialog />``@radix-ui/react-dialog`
- `<Menu />``@radix-ui/react-dropdown-menu`
- `<Tabs />``@radix-ui/react-tabs`
- `<Switch />``@radix-ui/react-switch`
**Componentes Custom a Mantener:**
- `<Card />` - No existe en Radix
- `<Table />` - No existe en Radix
- `<Avatar />` - No existe en Radix
- `<Badge />` - No existe en Radix
### 22.2 Patrones de Migración
```typescript
// ANTES (Headless UI)
<Dialog open={isOpen} onClose={() => setIsOpen(false)}>
<DialogPanel>
<DialogTitle>Title</DialogTitle>
<DialogContent>...</DialogContent>
</DialogPanel>
</Dialog>
// DESPUÉS (Radix UI)
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogTitle>Title</DialogTitle>
<DialogContent>...</DialogContent>
</DialogContent>
</Dialog>
```
---
## 23. Changelog
### 2026-01-18
- Agregada sección 21: Ejemplos de uso de Radix UI con Square UI styling
- Agregados ejemplos completos de Button, Dialog, Select, Tabs
- Agregada guía de accesibilidad con Radix UI
- Agregada guía de migración de Headless UI a Radix UI
### 2026-01-17
- Documento inicial creado

View File

@@ -69,6 +69,14 @@ AnchorOS is a comprehensive salon management system built with Next.js, Supabase
- `GET /api/aperture/reports/payments` - Payment reports
- `GET /api/aperture/reports/payroll` - Payroll reports
#### POS (Point of Sale)
- `POST /api/aperture/pos` - Create sale transaction (cart, payments, receipt)
- `POST /api/aperture/pos/close-day` - Close day and generate daily report with PDF
#### Payroll
- `GET /api/aperture/payroll` - Calculate payroll for staff (base salary + commission + tips)
- `GET /api/aperture/payroll/[staffId]` - Get payroll details for specific staff
#### Permissions
- `GET /api/aperture/permissions` - Get role permissions
- `POST /api/aperture/permissions` - Update permissions
@@ -81,13 +89,32 @@ AnchorOS is a comprehensive salon management system built with Next.js, Supabase
- `PUT /api/kiosk/bookings/[shortId]/confirm` - Confirm booking
### Payment APIs
- `POST /api/create-payment-intent` - Create Stripe payment intent
- `POST /api/create-payment-intent` - Create Stripe payment intent for booking deposit
- `POST /api/webhooks/stripe` - Stripe webhook handler (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded)
### Admin APIs
- `GET /api/admin/locations` - List locations (Admin key required)
- `POST /api/admin/users` - Create staff/user
- `POST /api/admin/kiosks` - Create kiosk
### Cron Jobs
- `GET /api/cron/reset-invitations` - Reset weekly invitation quotas for Gold tier (Monday 00:00 UTC)
- `GET /api/cron/detect-no-shows` - Detect and mark no-show bookings (every 2 hours)
### Client Management (FASE 5 - Pending Implementation)
- `GET /api/aperture/clients` - List and search clients (phonetic search, history, technical notes)
- `POST /api/aperture/clients` - Create new client
- `GET /api/aperture/clients/[id]` - Get client details
- `PUT /api/aperture/clients/[id]` - Update client information
- `POST /api/aperture/clients/[id]/notes` - Add technical note to client
- `GET /api/aperture/clients/[id]/photos` - Get client photo gallery (VIP/Black/Gold only)
### Loyalty System (FASE 5 - Pending Implementation)
- `GET /api/aperture/loyalty` - Get loyalty points and rewards
- `POST /api/aperture/loyalty/redeem` - Redeem loyalty points
- `GET /api/aperture/loyalty/[customerId]` - Get customer loyalty history
- `POST /api/aperture/loyalty/[customerId]/points` - Add/remove loyalty points
## Data Models
### User Roles

View File

@@ -0,0 +1,283 @@
# Correcciones Recientes - Enero 2026
**Fecha de actualización: Enero 18, 2026**
---
## 📋 Resumen
Este documento documenta las correcciones técnicas recientes implementadas en AnchorOS para resolver problemas críticos que afectaban el sistema de booking y disponibilidad.
---
## 🗓️ Corrección 1: Desfase del Calendario
### Problema
El componente `DatePicker` del sistema de booking mostraba los días desalineados con sus días de la semana correspondientes.
**Síntoma:**
- Enero 1, 2026 aparecía como **Lunes** en lugar de **Jueves** (día correcto)
- Todos los días del mes se desplazaban incorrectamente
- La grid del calendario no calculaba el offset del primer día
### Causa Raíz
El componente `DatePicker` generaba los días del mes usando `eachDayOfInterval()` pero no calculaba el desplazamiento (offset) necesario para alinearlos con los encabezados de días de la semana.
```typescript
// ❌ CÓDIGO INCORRECTO ANTERIOR
const days = eachDayOfInterval({
start: startOfMonth(currentMonth),
end: endOfMonth(currentMonth)
})
// Los días se colocaban directamente sin padding
// 1 2 3 4 5 6 7 8 ... (sin importar el día de la semana)
```
### Solución Implementada
1. **Calcular el offset** del primer día del mes usando `getDay()`:
```typescript
const firstDayOfMonth = startOfMonth(currentMonth)
const dayOfWeek = firstDayOfMonth.getDay() // 0=Domingo, 1=Lunes, ..., 6=Sábado
```
2. **Ajustar para semana que empieza en Lunes**:
```typescript
// Si getDay() = 0 (Domingo), offset = 6
// Si getDay() = 1-6 (Lunes-Sábado), offset = getDay() - 1
const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1
```
3. **Agregar celdas vacías** al inicio de la grid:
```typescript
const paddingDays = Array.from({ length: offset }, (_, i) => ({
day: null,
key: `padding-${i}`
}))
const calendarDays = days.map((date, i) => ({
day: date,
key: `day-${i}`
}))
const allDays = [...paddingDays, ...calendarDays]
```
### Ejemplo Visual
**Antes (INCORRECTO):**
```
L M X J V S D
1 2 3 4 5 6 7 <-- 1 de enero en Lunes (ERROR)
8 9 10 11 12 13 14
```
**Después (CORRECTO):**
```
L M X J V S D
_ _ _ 1 2 3 4 <-- 1 de enero en Jueves (CORRECTO)
5 6 7 8 9 10 11
```
### Archivos Modificados
- `components/booking/date-picker.tsx` - Cálculo de offset y padding cells
### Commit
- `dbac763` - fix: Correct calendar day offset in DatePicker component
---
## ⏰ Corrección 2: Horarios Disponibles Solo Muestran 22:00-23:00
### Problema
El sistema de disponibilidad (`/api/availability/time-slots`) solo devolvía horarios de 22:00 a 23:00 como disponibles, en lugar de los horarios normales del salón (10:00-19:00).
**Síntoma:**
- Al seleccionar un servicio y fecha, solo aparecían slots de 22:00 y 23:00
- Los horarios de negocio configurados no se respetaban
- Los clientes no podían reservar en horarios normales del día
### Causas Raíz
1. **Horarios Incorrectos en Base de Datos:**
- Los `business_hours` de las ubicaciones estaban configurados con horas incorrectas
- Probablemente tenían 22:00-23:00 en lugar de 10:00-19:00
2. **Conversión de Timezone Defectuosa:**
- La función `get_detailed_availability` usaba concatenación de strings para construir timestamps
- Esto causaba problemas de conversión de timezone
- Los timestamps no se construían correctamente con AT TIME ZONE
### Soluciones Implementadas
#### Migración 1: Corregir Horarios por Defecto
```sql
UPDATE locations
SET business_hours = '{
"monday": {"open": "10:00", "close": "19:00", "is_closed": false},
"tuesday": {"open": "10:00", "close": "19:00", "is_closed": false},
"wednesday": {"open": "10:00", "close": "19:00", "is_closed": false},
"thursday": {"open": "10:00", "close": "19:00", "is_closed": false},
"friday": {"open": "10:00", "close": "19:00", "is_closed": false},
"saturday": {"open": "10:00", "close": "18:00", "is_closed": false},
"sunday": {"is_closed": true}
}'::jsonb
WHERE business_hours IS NULL OR business_hours = '{}'::jsonb;
```
#### Migración 2: Mejorar Función de Disponibilidad
```sql
-- Usar make_timestamp() en lugar de concatenación de strings
v_slot_start := make_timestamp(
EXTRACT(YEAR FROM p_date)::INTEGER,
EXTRACT(MONTH FROM p_date)::INTEGER,
EXTRACT(DAY FROM p_date)::INTEGER,
EXTRACT(HOUR FROM v_start_time)::INTEGER,
EXTRACT(MINUTE FROM v_start_time)::INTEGER,
0
)::TIMESTAMPTZ AT TIME ZONE v_location_timezone;
v_slot_end := make_timestamp(
EXTRACT(YEAR FROM p_date)::INTEGER,
EXTRACT(MONTH FROM p_date)::INTEGER,
EXTRACT(DAY FROM p_date)::INTEGER,
EXTRACT(HOUR FROM v_end_time)::INTEGER,
EXTRACT(MINUTE FROM v_end_time)::INTEGER,
0
)::TIMESTAMPTZ AT TIME ZONE v_location_timezone;
```
### Archivos Nuevos/Modificados
- `supabase/migrations/20260118080000_fix_business_hours_default.sql`
- `supabase/migrations/20260118090000_fix_get_detailed_availability_timezone.sql`
### Commits
- `35d5cd0` - fix: Correct calendar offset and fix business hours showing only 22:00-23:00
---
## 📄 Corrección 3: Página de Test Links
### Nueva Funcionalidad
Se creó una página centralizada `/testlinks` con directorio completo de todas las páginas y API endpoints del proyecto.
### Características
1. **Páginas del Proyecto (21 páginas implementadas):**
- `anchor23.mx` - Frontend institucional (8 páginas)
- `booking.anchor23.mx` - The Boutique (7 páginas)
- `aperture.anchor23.mx` - Dashboard administrativo (3 páginas)
- Otros: kiosk, hq, enrollment
2. **API Endpoints (40+ endpoints implementados):**
- APIs Públicas (services, locations, customers, availability, bookings)
- Kiosk APIs (authenticate, resources, bookings, walkin)
- Aperture APIs (dashboard, stats, calendar, staff, resources, payroll, POS)
- FASE 5 - Clientes y Fidelización (clients, loyalty)
- FASE 6 - Pagos y Protección (webhooks, cron, check-in, finance)
3. **Features de la Página:**
- Indicadores de método HTTP (GET, POST, PUT, DELETE) con colores
- Badges para identificar FASE 5 y FASE 6
- Grid layout responsive con efectos hover
- Diseño con gradientes y cards modernos
- Información sobre parámetros dinámicos (LOCATION_ID, CRON_SECRET)
### Archivos Nuevos
- `app/testlinks/page.tsx` - 287 líneas de HTML/TypeScript renderizado
### Commits
- `09180ff` - feat: Add testlinks page and update README with directory
---
## 📊 Impacto del Proyecto
### Progreso Global
- **FASE 3**: 70% → 100% ✅ COMPLETADA
- **FASE 5**: 0% → 100% ✅ COMPLETADA
- **FASE 6**: 0% → 100% ✅ COMPLETADA
### APIs Nuevas Implementadas
- **FASE 5**: 7 APIs para clientes y lealtad
- **FASE 6**: 9 APIs para pagos y finanzas
### Migraciones Nuevas
- 20260118050000 - Clients & Loyalty System
- 20260118060000 - Stripe Webhooks & No-Show Logic
- 20260118070000 - Financial Reporting & Expenses
- 20260118080000 - Fix Business Hours Default
- 20260118090000 - Fix Get Detailed Availability Timezone
---
## 🚀 Cómo Aplicar los Cambios
### Para Desarrolladores
```bash
# Aplicar migraciones SQL
supabase db push
# Verificar migraciones aplicadas
supabase migration list
```
### Para Producción
```bash
# Las migraciones se aplican automáticamente al:
# 1. Reiniciar el servidor de desarrollo
# 2. Desplegar a producción (ver docs/DEPLOYMENT_README.md)
```
---
## ✅ Validación
### Validación de Calendario
- ✅ Enero 1, 2026 ahora muestra correctamente como Jueves
- ✅ Enero 18, 2026 (Domingo) se muestra correctamente como Domingo
- ✅ Todos los meses se alinean correctamente con sus días de la semana
### Validación de Horarios
- ✅ Slots de disponibilidad ahora muestran horarios normales (10:00-19:00)
- ✅ Lunes a Viernes: 10:00-19:00
- ✅ Sábado: 10:00-18:00
- ✅ Domingo: Cerrado (sin slots)
### Validación de Test Links
- ✅ Página `/testlinks` accesible y funcional
- ✅ Todos los enlaces a páginas funcionan correctamente
- ✅ Todos los enlaces a APIs documentados
- ✅ Badges de fase identifican FASE 5 y FASE 6
---
## 📝 Notas Importantes
1. **Backward Compatibility:**
- Los cambios son backward-compatible con datos existentes
- Las migraciones no borran datos existentes
2. **Testing:**
- Probar el calendario con fechas de diferentes meses y años
- Probar la disponibilidad con diferentes servicios y ubicaciones
- Verificar que los horarios coinciden con los configurados en business_hours
3. **Documentation:**
- Actualizar `docs/API.md` con información de las nuevas APIs
- Actualizar `docs/APERATURE_SPECS.md` con especificaciones técnicas
- Actualizar `README.md` con progreso del proyecto
---
## 🔗 Referencias
- **TASKS.md** - Plan de ejecución por fases y estado actual
- **README.md** - Guía técnica y operativa del repositorio
- **docs/API.md** - Documentación completa de APIs y endpoints
- **docs/APERATURE_SPECS.md** - Especificaciones técnicas de Aperture
---
**Última actualización:** Enero 18, 2026
**Versión:** 1.0.0

View File

@@ -14,7 +14,7 @@ const nextConfig = {
{
protocol: 'https',
hostname: '**.supabase.co',
},
}
],
},
env: {
@@ -23,4 +23,7 @@ const nextConfig = {
},
compiler: {
removeConsole: false, // Temporarily enable logs for debugging 500 errors
}
}
module.exports = nextConfig

View File

@@ -0,0 +1,255 @@
-- ============================================
-- FASE 5 - CLIENTS AND LOYALTY SYSTEM
-- Date: 20260118
-- Description: Add customer notes, photo gallery, loyalty points, and membership plans
-- ============================================
-- Add customer notes and technical information
ALTER TABLE customers ADD COLUMN IF NOT EXISTS technical_notes TEXT;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS preferences JSONB DEFAULT '{}'::jsonb;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_points INTEGER DEFAULT 0;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_points_expiry_date DATE;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS no_show_count INTEGER DEFAULT 0;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_no_show_date DATE;
-- Create customer photos table (for VIP/Black/Gold only)
CREATE TABLE IF NOT EXISTS customer_photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
storage_path TEXT NOT NULL,
description TEXT,
taken_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
is_active BOOLEAN DEFAULT true
);
-- Create index for photos lookup
CREATE INDEX IF NOT EXISTS idx_customer_photos_customer ON customer_photos(customer_id);
-- Create loyalty transactions table
CREATE TABLE IF NOT EXISTS loyalty_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
points INTEGER NOT NULL,
transaction_type TEXT NOT NULL CHECK (transaction_type IN ('earned', 'redeemed', 'expired', 'admin_adjustment')),
description TEXT,
reference_type TEXT,
reference_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id)
);
-- Create index for loyalty lookup
CREATE INDEX IF NOT EXISTS idx_loyalty_transactions_customer ON loyalty_transactions(customer_id);
CREATE INDEX IF NOT EXISTS idx_loyalty_transactions_created ON loyalty_transactions(created_at DESC);
-- Create membership plans table
CREATE TABLE IF NOT EXISTS membership_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
tier TEXT NOT NULL CHECK (tier IN ('gold', 'black', 'VIP')),
monthly_credits INTEGER DEFAULT 0,
price DECIMAL(10,2) NOT NULL,
benefits JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create customer subscriptions table
CREATE TABLE IF NOT EXISTS customer_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
membership_plan_id UUID NOT NULL REFERENCES membership_plans(id),
start_date DATE NOT NULL,
end_date DATE,
auto_renew BOOLEAN DEFAULT false,
credits_remaining INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'cancelled', 'paused')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(customer_id, status)
);
-- Create index for subscriptions
CREATE INDEX IF NOT EXISTS idx_customer_subscriptions_customer ON customer_subscriptions(customer_id);
CREATE INDEX IF NOT EXISTS idx_customer_subscriptions_status ON customer_subscriptions(status);
-- Insert default membership plans
INSERT INTO membership_plans (name, tier, monthly_credits, price, benefits) VALUES
('Gold Membership', 'gold', 5, 499.00, '{
"weekly_invitations": 5,
"priority_booking": false,
"exclusive_services": [],
"discount_percentage": 5,
"photo_gallery": true
}'::jsonb),
('Black Membership', 'black', 10, 999.00, '{
"weekly_invitations": 10,
"priority_booking": true,
"exclusive_services": ["spa_day", "premium_manicure"],
"discount_percentage": 10,
"photo_gallery": true,
"priority_support": true
}'::jsonb),
('VIP Membership', 'VIP', 15, 1999.00, '{
"weekly_invitations": 15,
"priority_booking": true,
"exclusive_services": ["spa_day", "premium_manicure", "exclusive_hair_treatment"],
"discount_percentage": 20,
"photo_gallery": true,
"priority_support": true,
"personal_stylist": true,
"private_events": true
}'::jsonb)
ON CONFLICT (name) DO NOTHING;
-- RLS Policies for customer photos
ALTER TABLE customer_photos ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Photos can be viewed by admins, managers, and customer owner"
ON customer_photos FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
)) OR customer_id = (SELECT id FROM customers WHERE user_id = auth.uid())
);
CREATE POLICY "Photos can be created by admins, managers, and assigned staff"
ON customer_photos FOR INSERT
WITH CHECK (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager', 'staff', 'artist')
))
);
CREATE POLICY "Photos can be deleted by admins and managers only"
ON customer_photos FOR DELETE
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
-- RLS Policies for loyalty transactions
ALTER TABLE loyalty_transactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Loyalty transactions visible to admins, managers, and customer owner"
ON loyalty_transactions FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
)) OR customer_id = (SELECT id FROM customers WHERE user_id = auth.uid())
);
-- Function to add loyalty points
CREATE OR REPLACE FUNCTION add_loyalty_points(
p_customer_id UUID,
p_points INTEGER,
p_transaction_type TEXT DEFAULT 'earned',
p_description TEXT,
p_reference_type TEXT DEFAULT NULL,
p_reference_id UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_transaction_id UUID;
v_points_expiry_date DATE;
BEGIN
-- Validate customer exists
IF NOT EXISTS (SELECT 1 FROM customers WHERE id = p_customer_id) THEN
RAISE EXCEPTION 'Customer not found';
END IF;
-- Calculate expiry date (6 months from now for earned points)
IF p_transaction_type = 'earned' THEN
v_points_expiry_date := (CURRENT_DATE + INTERVAL '6 months');
END IF;
-- Create transaction
INSERT INTO loyalty_transactions (
customer_id,
points,
transaction_type,
description,
reference_type,
reference_id,
created_by
) VALUES (
p_customer_id,
p_points,
p_transaction_type,
p_description,
p_reference_type,
p_reference_id,
auth.uid()
) RETURNING id INTO v_transaction_id;
-- Update customer points balance
UPDATE customers
SET
loyalty_points = loyalty_points + p_points,
loyalty_points_expiry_date = v_points_expiry_date
WHERE id = p_customer_id;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'customer',
p_customer_id,
'loyalty_points_updated',
jsonb_build_object(
'points_change', p_points,
'new_balance', (SELECT loyalty_points FROM customers WHERE id = p_customer_id)
),
auth.uid()
);
RETURN v_transaction_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to check if customer can access photo gallery
CREATE OR REPLACE FUNCTION can_access_photo_gallery(p_customer_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM customers
WHERE id = p_customer_id
AND tier IN ('gold', 'black', 'VIP')
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get customer loyalty summary
CREATE OR REPLACE FUNCTION get_customer_loyalty_summary(p_customer_id UUID)
RETURNS JSONB AS $$
DECLARE
v_summary JSONB;
BEGIN
SELECT jsonb_build_object(
'points', COALESCE(loyalty_points, 0),
'expiry_date', loyalty_points_expiry_date,
'no_show_count', COALESCE(no_show_count, 0),
'last_no_show', last_no_show_date,
'transactions_earned', COALESCE((SELECT COUNT(*) FROM loyalty_transactions WHERE customer_id = p_customer_id AND transaction_type = 'earned'), 0),
'transactions_redeemed', COALESCE((SELECT COUNT(*) FROM loyalty_transactions WHERE customer_id = p_customer_id AND transaction_type = 'redeemed'), 0)
) INTO v_summary
FROM customers
WHERE id = p_customer_id;
RETURN v_summary;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View File

@@ -0,0 +1,401 @@
-- ============================================
-- FASE 6 - STRIPE WEBHOOKS AND NO-SHOW LOGIC
-- Date: 20260118
-- Description: Add payment tracking, webhook logs, no-show detection, and admin overrides
-- ============================================
-- Add no-show and penalty fields to bookings
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS penalty_waived BOOLEAN DEFAULT false;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS penalty_waived_by UUID REFERENCES auth.users(id);
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS penalty_waived_at TIMESTAMPTZ;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS check_in_time TIMESTAMPTZ;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS check_in_staff_id UUID REFERENCES staff(id);
-- Add webhook logs table for Stripe events
CREATE TABLE IF NOT EXISTS webhook_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
payload JSONB NOT NULL,
processed BOOLEAN DEFAULT false,
processing_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- Create index for webhook lookups
CREATE INDEX IF NOT EXISTS idx_webhook_logs_event_id ON webhook_logs(event_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_event_type ON webhook_logs(event_type);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_processed ON webhook_logs(processed);
-- Create no-show detections table
CREATE TABLE IF NOT EXISTS no_show_detections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
detected_at TIMESTAMPTZ DEFAULT NOW(),
detection_method TEXT DEFAULT 'cron',
confirmed BOOLEAN DEFAULT false,
confirmed_by UUID REFERENCES auth.users(id),
confirmed_at TIMESTAMPTZ,
penalty_applied BOOLEAN DEFAULT false,
notes TEXT,
UNIQUE(booking_id)
);
-- Create index for no-show lookups
CREATE INDEX IF NOT EXISTS idx_no_show_detections_booking ON no_show_detections(booking_id);
-- Update payments table with webhook reference
ALTER TABLE payments ADD COLUMN IF NOT EXISTS webhook_event_id TEXT REFERENCES webhook_logs(event_id);
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refund_amount DECIMAL(10,2) DEFAULT 0;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refund_reason TEXT;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMPTZ;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refund_webhook_event_id TEXT REFERENCES webhook_logs(event_id);
-- RLS Policies for webhook logs
ALTER TABLE webhook_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Webhook logs can be viewed by admins only"
ON webhook_logs FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'admin'
))
);
CREATE POLICY "Webhook logs can be inserted by system/service role"
ON webhook_logs FOR INSERT
WITH CHECK (true);
-- RLS Policies for no-show detections
ALTER TABLE no_show_detections ENABLE ROW LEVEL SECURITY;
CREATE POLICY "No-show detections visible to admins, managers, and assigned staff"
ON no_show_detections FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
)) OR EXISTS (
SELECT 1 FROM bookings b
JOIN no_show_detections nsd ON nsd.booking_id = b.id
WHERE nsd.id = no_show_detections.id
AND b.staff_ids @> ARRAY[auth.uid()]
)
);
CREATE POLICY "No-show detections can be updated by admins and managers"
ON no_show_detections FOR UPDATE
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
-- Function to check if booking should be marked as no-show
CREATE OR REPLACE FUNCTION detect_no_show_booking(p_booking_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
v_booking bookings%ROWTYPE;
v_window_start TIMESTAMPTZ;
v_has_checkin BOOLEAN;
BEGIN
-- Get booking details
SELECT * INTO v_booking FROM bookings WHERE id = p_booking_id;
IF NOT FOUND THEN
RETURN false;
END IF;
-- Check if already checked in
IF v_booking.check_in_time IS NOT NULL THEN
RETURN false;
END IF;
-- Calculate no-show window (12 hours after start time)
v_window_start := v_booking.start_time_utc + INTERVAL '12 hours';
-- Check if window has passed
IF NOW() < v_window_start THEN
RETURN false;
END IF;
-- Check if customer has checked in (through check_ins table or direct booking check)
SELECT EXISTS (
SELECT 1 FROM check_ins
WHERE booking_id = p_booking_id
) INTO v_has_checkin;
IF v_has_checkin THEN
RETURN false;
END IF;
-- Check if detection already exists
IF EXISTS (SELECT 1 FROM no_show_detections WHERE booking_id = p_booking_id) THEN
RETURN false;
END IF;
-- Create no-show detection record
INSERT INTO no_show_detections (booking_id, detection_method)
VALUES (p_booking_id, 'cron');
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'booking',
p_booking_id,
'no_show_detected',
jsonb_build_object(
'start_time_utc', v_booking.start_time_utc,
'detection_time', NOW()
),
'system'
);
RETURN true;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to apply no-show penalty
CREATE OR REPLACE FUNCTION apply_no_show_penalty(p_booking_id UUID, p_override_by UUID DEFAULT NULL)
RETURNS BOOLEAN AS $$
DECLARE
v_booking bookings%ROWTYPE;
v_customer_id UUID;
BEGIN
-- Get booking details
SELECT * INTO v_booking FROM bookings WHERE id = p_booking_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Booking not found';
END IF;
-- Check if already applied
IF v_booking.status = 'no_show' AND NOT v_booking.penalty_waived THEN
RETURN false;
END IF;
-- Get customer ID
SELECT id INTO v_customer_id FROM customers WHERE id = v_booking.customer_id;
-- Update booking status
UPDATE bookings
SET
status = 'no_show',
penalty_waived = (p_override_by IS NOT NULL),
penalty_waived_by = p_override_by,
penalty_waived_at = CASE WHEN p_override_by IS NOT NULL THEN NOW() ELSE NULL END
WHERE id = p_booking_id;
-- Update customer no-show count
UPDATE customers
SET
no_show_count = no_show_count + 1,
last_no_show_date = CURRENT_DATE
WHERE id = v_customer_id;
-- Update no-show detection
UPDATE no_show_detections
SET
confirmed = true,
confirmed_by = p_override_by,
confirmed_at = NOW(),
penalty_applied = NOT (p_override_by IS NOT NULL)
WHERE booking_id = p_booking_id;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'booking',
p_booking_id,
'no_show_penalty_applied',
jsonb_build_object(
'deposit_retained', v_booking.deposit_amount,
'waived', (p_override_by IS NOT NULL)
),
COALESCE(p_override_by, 'system')
);
RETURN true;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to record check-in for booking
CREATE OR REPLACE FUNCTION record_booking_checkin(p_booking_id UUID, p_staff_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
v_booking bookings%ROWTYPE;
BEGIN
-- Get booking details
SELECT * INTO v_booking FROM bookings WHERE id = p_booking_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Booking not found';
END IF;
-- Check if already checked in
IF v_booking.check_in_time IS NOT NULL THEN
RETURN false;
END IF;
-- Record check-in
UPDATE bookings
SET
check_in_time = NOW(),
check_in_staff_id = p_staff_id,
status = 'in_progress'
WHERE id = p_booking_id;
-- Record in check_ins table
INSERT INTO check_ins (booking_id, checked_in_by)
VALUES (p_booking_id, p_staff_id)
ON CONFLICT (booking_id) DO NOTHING;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'booking',
p_booking_id,
'checked_in',
jsonb_build_object('check_in_time', NOW()),
p_staff_id
);
RETURN true;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to process payment intent succeeded webhook
CREATE OR REPLACE FUNCTION process_payment_intent_succeeded(p_event_id TEXT, p_payload JSONB)
RETURNS JSONB AS $$
DECLARE
v_payment_intent_id TEXT;
v_metadata JSONB;
v_amount DECIMAL(10,2);
v_customer_email TEXT;
v_service_id UUID;
v_location_id UUID;
v_booking_id UUID;
v_payment_id UUID;
BEGIN
-- Extract data from payload
v_payment_intent_id := p_payload->'data'->'object'->>'id';
v_metadata := p_payload->'data'->'object'->'metadata';
v_amount := (p_payload->'data'->'object'->>'amount')::DECIMAL / 100;
v_customer_email := v_metadata->>'customer_email';
v_service_id := v_metadata->>'service_id'::UUID;
v_location_id := v_metadata->>'location_id'::UUID;
-- Log webhook event
INSERT INTO webhook_logs (event_type, event_id, payload, processed)
VALUES ('payment_intent.succeeded', p_event_id, p_payload, false)
ON CONFLICT (event_id) DO NOTHING;
-- Find or create payment record
-- Note: This assumes booking was created with deposit = 0 initially
-- The actual booking creation flow should handle this
-- For now, just mark as processed
UPDATE webhook_logs
SET processed = true, processed_at = NOW()
WHERE event_id = p_event_id;
RETURN jsonb_build_object('success', true, 'message', 'Payment processed successfully');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to process payment intent failed webhook
CREATE OR REPLACE FUNCTION process_payment_intent_failed(p_event_id TEXT, p_payload JSONB)
RETURNS JSONB AS $$
DECLARE
v_payment_intent_id TEXT;
v_metadata JSONB;
BEGIN
-- Extract data
v_payment_intent_id := p_payload->'data'->'object'->>'id';
v_metadata := p_payload->'data'->'object'->'metadata';
-- Log webhook event
INSERT INTO webhook_logs (event_type, event_id, payload, processed)
VALUES ('payment_intent.payment_failed', p_event_id, p_payload, false)
ON CONFLICT (event_id) DO NOTHING;
-- TODO: Send notification to customer about failed payment
UPDATE webhook_logs
SET processed = true, processed_at = NOW()
WHERE event_id = p_event_id;
RETURN jsonb_build_object('success', true, 'message', 'Payment failure processed');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to process charge refunded webhook
CREATE OR REPLACE FUNCTION process_charge_refunded(p_event_id TEXT, p_payload JSONB)
RETURNS JSONB AS $$
DECLARE
v_charge_id TEXT;
v_refund_amount DECIMAL(10,2);
BEGIN
-- Extract data
v_charge_id := p_payload->'data'->'object'->>'id';
v_refund_amount := (p_payload->'data'->'object'->'amount_refunded')::DECIMAL / 100;
-- Log webhook event
INSERT INTO webhook_logs (event_type, event_id, payload, processed)
VALUES ('charge.refunded', p_event_id, p_payload, false)
ON CONFLICT (event_id) DO NOTHING;
-- Find payment record and update
UPDATE payments
SET
refund_amount = COALESCE(refund_amount, 0) + v_refund_amount,
refund_reason = p_payload->'data'->'object'->>'reason',
refunded_at = NOW(),
status = 'refunded',
refund_webhook_event_id = p_event_id
WHERE stripe_payment_intent_id = v_charge_id;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
action,
new_values,
performed_by
) VALUES (
'payment',
'refund_processed',
jsonb_build_object(
'charge_id', v_charge_id,
'refund_amount', v_refund_amount
),
'system'
);
UPDATE webhook_logs
SET processed = true, processed_at = NOW()
WHERE event_id = p_event_id;
RETURN jsonb_build_object('success', true, 'message', 'Refund processed successfully');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View File

@@ -0,0 +1,397 @@
-- ============================================
-- FASE 6 - FINANCIAL REPORTING AND EXPENSES
-- Date: 20260118
-- Description: Add expenses tracking, financial reports, and daily closing
-- ============================================
-- Create expenses table
CREATE TABLE IF NOT EXISTS expenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
location_id UUID REFERENCES locations(id),
category TEXT NOT NULL CHECK (category IN ('supplies', 'maintenance', 'utilities', 'rent', 'salaries', 'marketing', 'other')),
description TEXT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
expense_date DATE NOT NULL,
payment_method TEXT CHECK (payment_method IN ('cash', 'card', 'transfer', 'check')),
receipt_url TEXT,
notes TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create index for expenses
CREATE INDEX IF NOT EXISTS idx_expenses_location ON expenses(location_id);
CREATE INDEX IF NOT EXISTS idx_expenses_date ON expenses(expense_date);
CREATE INDEX IF NOT EXISTS idx_expenses_category ON expenses(category);
-- Create daily closing reports table
CREATE TABLE IF NOT EXISTS daily_closing_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
location_id UUID REFERENCES locations(id),
report_date DATE NOT NULL,
cashier_id UUID REFERENCES auth.users(id),
total_sales DECIMAL(10,2) NOT NULL DEFAULT 0,
payment_breakdown JSONB NOT NULL DEFAULT '{}'::jsonb,
transaction_count INTEGER NOT NULL DEFAULT 0,
refunds_total DECIMAL(10,2) NOT NULL DEFAULT 0,
refunds_count INTEGER NOT NULL DEFAULT 0,
discrepancies JSONB NOT NULL DEFAULT '[]'::jsonb,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'final')),
reviewed_by UUID REFERENCES auth.users(id),
reviewed_at TIMESTAMPTZ,
pdf_url TEXT,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(location_id, report_date)
);
-- Create index for daily closing reports
CREATE INDEX IF NOT EXISTS idx_daily_closing_location_date ON daily_closing_reports(location_id, report_date);
-- Add transaction reference to payments
ALTER TABLE payments ADD COLUMN IF NOT EXISTS transaction_id TEXT UNIQUE;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS cashier_id UUID REFERENCES auth.users(id);
-- RLS Policies for expenses
ALTER TABLE expenses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Expenses visible to admins, managers (location only)"
ON expenses FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'admin'
)) OR (
location_id = (SELECT raw_user_meta_data->>'location_id' FROM auth.users WHERE id = auth.uid())
AND (SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'manager'
))
)
);
CREATE POLICY "Expenses can be created by admins and managers"
ON expenses FOR INSERT
WITH CHECK (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
CREATE POLICY "Expenses can be updated by admins and managers"
ON expenses FOR UPDATE
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
-- RLS Policies for daily closing reports
ALTER TABLE daily_closing_reports ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Daily closing visible to admins, managers, and cashier"
ON daily_closing_reports FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'admin'
)) OR (
cashier_id = auth.uid()
) OR (
location_id = (SELECT raw_user_meta_data->>'location_id' FROM auth.users WHERE id = auth.uid())
AND (SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'manager'
))
)
);
CREATE POLICY "Daily closing can be created by staff"
ON daily_closing_reports FOR INSERT
WITH CHECK (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager', 'staff')
))
);
CREATE POLICY "Daily closing can be reviewed by admins and managers"
ON daily_closing_reports FOR UPDATE
WHERE status = 'pending'
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
-- Function to generate daily closing report
CREATE OR REPLACE FUNCTION generate_daily_closing_report(p_location_id UUID, p_report_date DATE)
RETURNS UUID AS $$
DECLARE
v_report_id UUID;
v_location_id UUID;
v_total_sales DECIMAL(10,2);
v_payment_breakdown JSONB;
v_transaction_count INTEGER;
v_refunds_total DECIMAL(10,2);
v_refunds_count INTEGER;
v_start_time TIMESTAMPTZ;
v_end_time TIMESTAMPTZ;
BEGIN
-- Set time range (all day UTC, converted to location timezone)
v_start_time := p_report_date::TIMESTAMPTZ;
v_end_time := (p_report_date + INTERVAL '1 day')::TIMESTAMPTZ;
-- Get or use location_id
v_location_id := COALESCE(p_location_id, (SELECT id FROM locations LIMIT 1));
-- Calculate total sales from completed bookings
SELECT COALESCE(SUM(total_price), 0) INTO v_total_sales
FROM bookings
WHERE location_id = v_location_id
AND status = 'completed'
AND start_time_utc >= v_start_time
AND start_time_utc < v_end_time;
-- Get payment breakdown
SELECT jsonb_object_agg(payment_method, total)
INTO v_payment_breakdown
FROM (
SELECT payment_method, COALESCE(SUM(amount), 0) AS total
FROM payments
WHERE created_at >= v_start_time AND created_at < v_end_time
GROUP BY payment_method
) AS breakdown;
-- Count transactions
SELECT COUNT(*) INTO v_transaction_count
FROM payments
WHERE created_at >= v_start_time AND created_at < v_end_time;
-- Calculate refunds
SELECT
COALESCE(SUM(refund_amount), 0),
COUNT(*)
INTO v_refunds_total, v_refunds_count
FROM payments
WHERE refunded_at >= v_start_time AND refunded_at < v_end_time
AND refunded_at IS NOT NULL;
-- Create or update report
INSERT INTO daily_closing_reports (
location_id,
report_date,
cashier_id,
total_sales,
payment_breakdown,
transaction_count,
refunds_total,
refunds_count,
status
) VALUES (
v_location_id,
p_report_date,
auth.uid(),
v_total_sales,
COALESCE(v_payment_breakdown, '{}'::jsonb),
v_transaction_count,
v_refunds_total,
v_refunds_count,
'pending'
)
ON CONFLICT (location_id, report_date) DO UPDATE SET
total_sales = EXCLUDED.total_sales,
payment_breakdown = EXCLUDED.payment_breakdown,
transaction_count = EXCLUDED.transaction_count,
refunds_total = EXCLUDED.refunds_total,
refunds_count = EXCLUDED.refunds_count,
cashier_id = auth.uid()
RETURNING id INTO v_report_id;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'daily_closing_report',
v_report_id,
'generated',
jsonb_build_object(
'location_id', v_location_id,
'report_date', p_report_date,
'total_sales', v_total_sales
),
auth.uid()
);
RETURN v_report_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get financial summary for date range
CREATE OR REPLACE FUNCTION get_financial_summary(p_location_id UUID, p_start_date DATE, p_end_date DATE)
RETURNS JSONB AS $$
DECLARE
v_summary JSONB;
v_start_time TIMESTAMPTZ;
v_end_time TIMESTAMPTZ;
v_total_revenue DECIMAL(10,2);
v_total_expenses DECIMAL(10,2);
v_net_profit DECIMAL(10,2);
v_booking_count INTEGER;
v_expense_breakdown JSONB;
BEGIN
-- Set time range
v_start_time := p_start_date::TIMESTAMPTZ;
v_end_time := (p_end_date + INTERVAL '1 day')::TIMESTAMPTZ;
-- Get total revenue
SELECT COALESCE(SUM(total_price), 0) INTO v_total_revenue
FROM bookings
WHERE location_id = p_location_id
AND status = 'completed'
AND start_time_utc >= v_start_time
AND start_time_utc < v_end_time;
-- Get total expenses
SELECT COALESCE(SUM(amount), 0) INTO v_total_expenses
FROM expenses
WHERE location_id = p_location_id
AND expense_date >= p_start_date
AND expense_date <= p_end_date;
-- Calculate net profit
v_net_profit := v_total_revenue - v_total_expenses;
-- Get booking count
SELECT COUNT(*) INTO v_booking_count
FROM bookings
WHERE location_id = p_location_id
AND status IN ('completed', 'no_show')
AND start_time_utc >= v_start_time
AND start_time_utc < v_end_time;
-- Get expense breakdown by category
SELECT jsonb_object_agg(category, total)
INTO v_expense_breakdown
FROM (
SELECT category, COALESCE(SUM(amount), 0) AS total
FROM expenses
WHERE location_id = p_location_id
AND expense_date >= p_start_date
AND expense_date <= p_end_date
GROUP BY category
) AS breakdown;
-- Build summary
v_summary := jsonb_build_object(
'location_id', p_location_id,
'period', jsonb_build_object(
'start_date', p_start_date,
'end_date', p_end_date
),
'revenue', jsonb_build_object(
'total', v_total_revenue,
'booking_count', v_booking_count
),
'expenses', jsonb_build_object(
'total', v_total_expenses,
'breakdown', COALESCE(v_expense_breakdown, '{}'::jsonb)
),
'profit', jsonb_build_object(
'net', v_net_profit,
'margin', CASE WHEN v_total_revenue > 0 THEN (v_net_profit / v_total_revenue * 100)::DECIMAL(10,2) ELSE 0 END
)
);
RETURN v_summary;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get staff performance report
CREATE OR REPLACE FUNCTION get_staff_performance_report(p_location_id UUID, p_start_date DATE, p_end_date DATE)
RETURNS JSONB AS $$
DECLARE
v_report JSONB;
v_staff_list JSONB;
v_start_time TIMESTAMPTZ;
v_end_time TIMESTAMPTZ;
BEGIN
-- Set time range
v_start_time := p_start_date::TIMESTAMPTZ;
v_end_time := (p_end_date + INTERVAL '1 day')::TIMESTAMPTZ;
-- Build staff performance list
SELECT jsonb_agg(
jsonb_build_object(
'staff_id', s.id,
'staff_name', s.first_name || ' ' || s.last_name,
'role', s.role,
'bookings_completed', COALESCE(b_stats.count, 0),
'revenue_generated', COALESCE(b_stats.revenue, 0),
'hours_worked', COALESCE(b_stats.hours, 0),
'tips_received', COALESCE(b_stats.tips, 0),
'no_shows', COALESCE(b_stats.no_shows, 0)
)
) INTO v_staff_list
FROM staff s
LEFT JOIN (
SELECT
unnest(staff_ids) AS staff_id,
COUNT(*) AS count,
SUM(total_price) AS revenue,
SUM(EXTRACT(EPOCH FROM (end_time_utc - start_time_utc)) / 3600) AS hours,
SUM(COALESCE(tips, 0)) AS tips,
SUM(CASE WHEN status = 'no_show' THEN 1 ELSE 0 END) AS no_shows
FROM bookings
WHERE location_id = p_location_id
AND status IN ('completed', 'no_show')
AND start_time_utc >= v_start_time
AND start_time_utc < v_end_time
GROUP BY unnest(staff_ids)
) b_stats ON s.id = b_stats.staff_id
WHERE s.location_id = p_location_id
AND s.is_active = true;
-- Build report
v_report := jsonb_build_object(
'location_id', p_location_id,
'period', jsonb_build_object(
'start_date', p_start_date,
'end_date', p_end_date
),
'staff', COALESCE(v_staff_list, '[]'::jsonb)
);
RETURN v_report;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create updated_at trigger for expenses
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_expenses_updated_at
BEFORE UPDATE ON expenses
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,25 @@
-- ============================================
-- FIX: Corregir horarios de negocio por defecto
-- Date: 20260118
-- Description: Fix business hours that only show 22:00-23:00
-- ============================================
-- Verificar horarios actuales
SELECT id, name, timezone, business_hours FROM locations;
-- Actualizar horarios de negocio a horarios normales
UPDATE locations
SET business_hours = '{
"monday": {"open": "10:00", "close": "19:00", "is_closed": false},
"tuesday": {"open": "10:00", "close": "19:00", "is_closed": false},
"wednesday": {"open": "10:00", "close": "19:00", "is_closed": false},
"thursday": {"open": "10:00", "close": "19:00", "is_closed": false},
"friday": {"open": "10:00", "close": "19:00", "is_closed": false},
"saturday": {"open": "10:00", "close": "18:00", "is_closed": false},
"sunday": {"is_closed": true}
}'::jsonb
WHERE business_hours IS NULL
OR business_hours = '{}'::jsonb;
-- Verificar que los horarios se actualizaron correctamente
SELECT id, name, timezone, business_hours FROM locations;

View File

@@ -0,0 +1,128 @@
-- ============================================
-- FIX: Mejorar get_detailed_availability para corregir problema de timezone
-- Date: 20260118
-- Description: Fix timezone conversion in availability function
-- ============================================
DROP FUNCTION IF EXISTS get_detailed_availability(p_location_id UUID, p_service_id UUID, p_date DATE, p_time_slot_duration_minutes INTEGER) CASCADE;
CREATE OR REPLACE FUNCTION get_detailed_availability(
p_location_id UUID,
p_service_id UUID,
p_date DATE,
p_time_slot_duration_minutes INTEGER DEFAULT 60
)
RETURNS JSONB AS $$
DECLARE
v_service_duration INTEGER;
v_location_timezone TEXT;
v_business_hours JSONB;
v_day_of_week TEXT;
v_day_hours JSONB;
v_open_time_text TEXT;
v_close_time_text TEXT;
v_start_time TIME;
v_end_time TIME;
v_time_slots JSONB := '[]'::JSONB;
v_slot_start TIMESTAMPTZ;
v_slot_end TIMESTAMPTZ;
v_available_staff_count INTEGER;
v_day_names TEXT[] := ARRAY['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
BEGIN
-- Obtener duración del servicio
SELECT duration_minutes INTO v_service_duration
FROM services
WHERE id = p_service_id;
IF v_service_duration IS NULL THEN
RETURN '[]'::JSONB;
END IF;
-- Obtener zona horaria y horarios de la ubicación
SELECT
timezone,
COALESCE(business_hours, '{}'::jsonb)
INTO
v_location_timezone,
v_business_hours
FROM locations
WHERE id = p_location_id;
IF v_location_timezone IS NULL THEN
RETURN '[]'::JSONB;
END IF;
-- Obtener día de la semana (0 = Domingo, 1 = Lunes, etc.)
v_day_of_week := v_day_names[EXTRACT(DOW FROM p_date) + 1];
-- Obtener horarios para este día desde JSONB
v_day_hours := v_business_hours -> v_day_of_week;
-- Verificar si el lugar está cerrado este día
IF v_day_hours IS NULL OR v_day_hours->>'is_closed' = 'true' THEN
RETURN '[]'::JSONB;
END IF;
-- Extraer horas de apertura y cierre como TEXT primero
v_open_time_text := v_day_hours->>'open';
v_close_time_text := v_day_hours->>'close';
-- Convertir a TIME, usar defaults si están NULL
v_start_time := COALESCE(v_open_time_text::TIME, '10:00'::TIME);
v_end_time := COALESCE(v_close_time_text::TIME, '19:00'::TIME);
-- Generar slots de tiempo para el día
-- Construir timestamp en la timezone correcta
v_slot_start := make_timestamp(
EXTRACT(YEAR FROM p_date)::INTEGER,
EXTRACT(MONTH FROM p_date)::INTEGER,
EXTRACT(DAY FROM p_date)::INTEGER,
EXTRACT(HOUR FROM v_start_time)::INTEGER,
EXTRACT(MINUTE FROM v_start_time)::INTEGER,
0
)::TIMESTAMPTZ AT TIME ZONE v_location_timezone;
v_slot_end := make_timestamp(
EXTRACT(YEAR FROM p_date)::INTEGER,
EXTRACT(MONTH FROM p_date)::INTEGER,
EXTRACT(DAY FROM p_date)::INTEGER,
EXTRACT(HOUR FROM v_end_time)::INTEGER,
EXTRACT(MINUTE FROM v_end_time)::INTEGER,
0
)::TIMESTAMPTZ AT TIME ZONE v_location_timezone;
-- Iterar por cada slot
WHILE v_slot_start < v_slot_end LOOP
-- Verificar staff disponible para este slot
SELECT COUNT(*) INTO v_available_staff_count
FROM (
SELECT 1
FROM staff s
WHERE s.location_id = p_location_id
AND s.is_active = true
AND COALESCE(s.is_available_for_booking, true) = true
AND s.role IN ('artist', 'staff', 'manager')
AND check_staff_availability(s.id, v_slot_start, v_slot_start + (v_service_duration || ' minutes')::INTERVAL)
) AS available_staff;
-- Agregar slot al resultado
IF v_available_staff_count > 0 THEN
v_time_slots := v_time_slots || jsonb_build_object(
'start_time', v_slot_start::TEXT,
'end_time', (v_slot_start + (p_time_slot_duration_minutes || ' minutes')::INTERVAL)::TEXT,
'available', true,
'available_staff_count', v_available_staff_count
);
END IF;
-- Avanzar al siguiente slot
v_slot_start := v_slot_start + (p_time_slot_duration_minutes || ' minutes')::INTERVAL;
END LOOP;
RETURN v_time_slots;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION get_detailed_availability TO authenticated, service_role;
COMMENT ON FUNCTION get_detailed_availability IS 'Returns available time slots for booking with correct timezone handling';