mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 19:24:32 +00:00
Compare commits
9 Commits
c220e7f30f
...
1b9230f2be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b9230f2be | ||
|
|
88ea79f496 | ||
|
|
e3952bf8ea | ||
|
|
37547ea1bb | ||
|
|
35d5cd058c | ||
|
|
dbac7631e5 | ||
|
|
09180ff77d | ||
|
|
bb25d6bde6 | ||
|
|
f6832c1e29 |
199
README.md
199
README.md
@@ -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
249
TASKS.md
@@ -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
|
||||
|
||||
60
app/api/aperture/bookings/check-in/route.ts
Normal file
60
app/api/aperture/bookings/check-in/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
53
app/api/aperture/bookings/no-show/route.ts
Normal file
53
app/api/aperture/bookings/no-show/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
84
app/api/aperture/clients/[id]/notes/route.ts
Normal file
84
app/api/aperture/clients/[id]/notes/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
152
app/api/aperture/clients/[id]/photos/route.ts
Normal file
152
app/api/aperture/clients/[id]/photos/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
173
app/api/aperture/clients/[id]/route.ts
Normal file
173
app/api/aperture/clients/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
154
app/api/aperture/clients/route.ts
Normal file
154
app/api/aperture/clients/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
68
app/api/aperture/finance/daily-closing/route.ts
Normal file
68
app/api/aperture/finance/daily-closing/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
143
app/api/aperture/finance/expenses/route.ts
Normal file
143
app/api/aperture/finance/expenses/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
app/api/aperture/finance/route.ts
Normal file
49
app/api/aperture/finance/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
app/api/aperture/finance/staff-performance/route.ts
Normal file
49
app/api/aperture/finance/staff-performance/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
134
app/api/aperture/loyalty/[customerId]/route.ts
Normal file
134
app/api/aperture/loyalty/[customerId]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
92
app/api/aperture/loyalty/route.ts
Normal file
92
app/api/aperture/loyalty/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
95
app/api/cron/detect-no-shows/route.ts
Normal file
95
app/api/cron/detect-no-shows/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 ')) {
|
||||
|
||||
106
app/api/webhooks/stripe/route.ts
Normal file
106
app/api/webhooks/stripe/route.ts
Normal 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
287
app/testlinks/page.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
792
docs/APERATURE_SPECS.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
29
docs/API.md
29
docs/API.md
@@ -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
|
||||
|
||||
283
docs/RECENT_FIXES_JAN_2026.md
Normal file
283
docs/RECENT_FIXES_JAN_2026.md
Normal 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
|
||||
@@ -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
|
||||
255
supabase/migrations/20260118050000_clients_loyalty_system.sql
Normal file
255
supabase/migrations/20260118050000_clients_loyalty_system.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user