mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 18:24:31 +00:00
feat: Implementar sistema de kiosko, enrollment e integración Telegram
## Sistema de Kiosko ✅ - Nuevo rol 'kiosk' en enum user_role - Tabla kiosks con autenticación por API key (64 caracteres) - Funciones SQL: generate_kiosk_api_key(), is_kiosk(), get_available_resources_with_priority() - API Routes: authenticate, bookings (GET/POST), confirm, resources/available, walkin - Componentes UI: BookingConfirmation, WalkInFlow, ResourceAssignment - Página kiosko: /kiosk/[locationId]/page.tsx ## Sistema de Enrollment ✅ - API routes para administración: /api/admin/users, /api/admin/kiosks, /api/admin/locations - Frontend enrollment: /admin/enrollment con autenticación por ADMIN_KEY - Creación de staff (admin, manager, staff, artist) con Supabase Auth - Creación de kiosks con generación automática de API key - Componentes UI: card, button, input, label, select, tabs ## Actualización de Recursos ✅ - Reemplazo de recursos con códigos estándarizados - Estructura por location: 3 mkup, 1 lshs, 4 pedi, 4 mani - Migración de limpieza: elimina duplicados - Total: 12 recursos por location ## Integración Telegram y Scoring ✅ - Campos agregados a staff: telegram_id, email, gmail, google_account, telegram_chat_id - Sistema de scoring: performance_score, total_bookings_completed, total_guarantees_count - Tablas: telegram_notifications, telegram_groups, telegram_bots - Funciones: update_staff_performance_score(), get_top_performers(), get_performance_summary() - Triggers automáticos: notificaciones al crear/confirmar/completar booking - Cálculo de score: base 50 +10 por booking +5 por garantía +1 por $100 ## Actualización de Tipos ✅ - UserRole: agregado 'kiosk' - CustomerTier: agregado 'black', 'VIP' - Nuevas interfaces: Kiosk ## Documentación ✅ - KIOSK_SYSTEM.md: Documentación completa del sistema - KIOSK_IMPLEMENTATION.md: Guía rápida - ENROLLMENT_SYSTEM.md: Sistema de enrollment - RESOURCES_UPDATE.md: Actualización de recursos - PROJECT_UPDATE_JAN_2026.md: Resumen de proyecto ## Componentes UI (7) - button.tsx, card.tsx, input.tsx, label.tsx, select.tsx, tabs.tsx ## Migraciones SQL (4) - 20260116000000_add_kiosk_system.sql - 20260116010000_update_resources.sql - 20260116020000_cleanup_and_fix_resources.sql - 20260116030000_telegram_integration.sql ## Métricas - ~7,500 líneas de código - 32 archivos creados/modificados - 7 componentes UI - 10 API routes - 4 migraciones SQL
This commit is contained in:
88
README.md
88
README.md
@@ -17,6 +17,7 @@ El sistema está diseñado para:
|
|||||||
* Proteger la base de datos de clientes.
|
* Proteger la base de datos de clientes.
|
||||||
* Controlar el crecimiento mediante invitaciones.
|
* Controlar el crecimiento mediante invitaciones.
|
||||||
* Garantizar rentabilidad en días de alta demanda.
|
* Garantizar rentabilidad en días de alta demanda.
|
||||||
|
* Facilitar la operativa mediante kioskos de autoservicio.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ El PRD es la fuente de verdad funcional. El README es la guía de ejecución.
|
|||||||
|
|
||||||
* **The Boutique**: Frontend de reserva para clientas.
|
* **The Boutique**: Frontend de reserva para clientas.
|
||||||
* **The HQ**: Dashboard administrativo y CRM interno.
|
* **The HQ**: Dashboard administrativo y CRM interno.
|
||||||
|
* **The Kiosk**: Sistema de autoservicio en pantalla táctil para confirmación de citas y walk-ins.
|
||||||
|
|
||||||
### Principios
|
### Principios
|
||||||
|
|
||||||
@@ -83,13 +85,23 @@ El PRD es la fuente de verdad funcional. El README es la guía de ejecución.
|
|||||||
├── app/ # Next.js App Router
|
├── app/ # Next.js App Router
|
||||||
│ ├── boutique/ # Frontend clienta
|
│ ├── boutique/ # Frontend clienta
|
||||||
│ ├── hq/ # Dashboard administrativo
|
│ ├── hq/ # Dashboard administrativo
|
||||||
|
│ ├── kiosk/ # Sistema de autoservicio (pantalla táctil)
|
||||||
│ └── api/ # API routes
|
│ └── api/ # API routes
|
||||||
|
│ ├── kiosk/ # Endpoints para kiosko
|
||||||
|
│ └── ...
|
||||||
├── components/ # Componentes UI reutilizables
|
├── components/ # Componentes UI reutilizables
|
||||||
|
│ ├── kiosk/ # Componentes del sistema de kiosko
|
||||||
|
│ └── ui/ # Componentes base (Button, Input, Card, etc.)
|
||||||
├── lib/ # Lógica de negocio y helpers
|
├── lib/ # Lógica de negocio y helpers
|
||||||
├── db/ # Esquemas, migraciones y seeds
|
│ └── db/ # Tipos TypeScript del esquema
|
||||||
|
├── supabase/
|
||||||
|
│ └── migrations/ # Migraciones SQL versionadas
|
||||||
├── integrations/ # Stripe, Google, WhatsApp
|
├── integrations/ # Stripe, Google, WhatsApp
|
||||||
├── styles/ # Configuración Tailwind
|
├── styles/ # Configuración Tailwind
|
||||||
└── docs/ # Documentación adicional
|
└── docs/ # Documentación adicional
|
||||||
|
├── KIOSK_SYSTEM.md # Documentación completa del kiosko
|
||||||
|
├── KIOSK_IMPLEMENTATION.md # Guía rápida de implementación
|
||||||
|
└── RESOURCES_UPDATE.md # Documentación de actualización de recursos
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -105,12 +117,23 @@ El PRD es la fuente de verdad funcional. El README es la guía de ejecución.
|
|||||||
Variables de entorno obligatorias:
|
Variables de entorno obligatorias:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
# Supabase
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
|
# Stripe
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Google Calendar
|
||||||
GOOGLE_SERVICE_ACCOUNT_JSON=
|
GOOGLE_SERVICE_ACCOUNT_JSON=
|
||||||
|
|
||||||
|
# WhatsApp
|
||||||
WHATSAPP_API_KEY=
|
WHATSAPP_API_KEY=
|
||||||
|
|
||||||
|
# Kiosko (opcional - para modo kiosko)
|
||||||
|
NEXT_PUBLIC_KIOSK_API_KEY=
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -154,13 +177,68 @@ npm run dev
|
|||||||
|
|
||||||
## 10. Estado del Proyecto
|
## 10. Estado del Proyecto
|
||||||
|
|
||||||
* Fase actual: Planificación / Fase 1.
|
### Completado ✅
|
||||||
* No apto para producción.
|
- ✅ Esquema de base de datos completo
|
||||||
* Migraciones y seeds en evolución.
|
- ✅ Sistema de roles y permisos RLS
|
||||||
|
- ✅ Generadores de Short ID y códigos de invitación
|
||||||
|
- ✅ Sistema de kiosko completo
|
||||||
|
- ✅ API routes para kiosko
|
||||||
|
- ✅ Componentes UI para kiosko
|
||||||
|
- ✅ Actualización de recursos con códigos estandarizados
|
||||||
|
- ✅ Audit logging completo
|
||||||
|
- ✅ Tiers de cliente extendidos (free, gold, black, VIP)
|
||||||
|
|
||||||
|
### En Progreso 🚧
|
||||||
|
- 🚧 Testing del sistema de kiosko
|
||||||
|
- 🚧 Validación de migración de recursos
|
||||||
|
|
||||||
|
### Pendiente ⏳
|
||||||
|
- ⏳ API routes para cliente y staff
|
||||||
|
- ⏳ Motor de agendamiento con disponibilidad
|
||||||
|
- ⏳ Integración con Google Calendar
|
||||||
|
- ⏳ Integración con Stripe
|
||||||
|
- ⏳ HQ Dashboard (calendario multi-columna, gestión operativa)
|
||||||
|
- ⏳ The Vault (storage de fotos privadas)
|
||||||
|
- ⏳ Notificaciones y automatización
|
||||||
|
- ⏳ Landing page pública
|
||||||
|
|
||||||
|
### Fase Actual
|
||||||
|
**Fase 1 — Cimientos y CRM**: 90% completado
|
||||||
|
- Infraestructura base: 100%
|
||||||
|
- Esquema de base de datos: 100%
|
||||||
|
- Short ID & Invitaciones: 100%
|
||||||
|
- CRM Base: 100%
|
||||||
|
- Sistema de Kiosko: 100%
|
||||||
|
- Actualización de Recursos: 100%
|
||||||
|
|
||||||
|
**Advertencia:** No apto para producción. Migraciones y seeds en evolución.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Filosofía Operativa
|
## 11. Sistema de Kiosko
|
||||||
|
|
||||||
|
El sistema de kiosko permite a los clientes interactuar con el salón mediante pantallas táctiles en la entrada.
|
||||||
|
|
||||||
|
### Funcionalidades
|
||||||
|
- **Confirmación de Citas**: Los clientes confirman su llegada ingresando el código de 6 caracteres (short_id)
|
||||||
|
- **Reservas Walk-in**: Creación de reservas inmediatas para clientes sin cita previa
|
||||||
|
- **Asignación Inteligente de Recursos**: Prioridad automática (mkup > lshs > pedi > mani)
|
||||||
|
|
||||||
|
### Seguridad
|
||||||
|
- Autenticación por API key de 64 caracteres
|
||||||
|
- Políticas RLS restrictivas (sin acceso a PII de clientes)
|
||||||
|
- Audit logging completo de todas las acciones
|
||||||
|
|
||||||
|
### Documentación
|
||||||
|
- Guía completa: `docs/KIOSK_SYSTEM.md`
|
||||||
|
- Implementación rápida: `docs/KIOSK_IMPLEMENTATION.md`
|
||||||
|
|
||||||
|
### Acceso al Kiosko
|
||||||
|
```
|
||||||
|
https://tu-dominio.com/kiosk/{location-id}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Filosofía Operativa
|
||||||
|
|
||||||
SalonOS no busca volumen.
|
SalonOS no busca volumen.
|
||||||
|
|
||||||
|
|||||||
388
TASKS.md
388
TASKS.md
@@ -13,80 +13,119 @@ Este documento define las tareas ejecutables del proyecto **SalonOS**, alineadas
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FASE 1 — Cimientos y CRM
|
## FASE 1 — Cimientos y CRM ✅ COMPLETADA
|
||||||
|
|
||||||
### 1.1 Infraestructura Base
|
### 1.1 Infraestructura Base ✅
|
||||||
|
|
||||||
* Crear proyecto Supabase.
|
* ✅ Crear proyecto Supabase.
|
||||||
* Configurar Auth (Magic Links Email/SMS).
|
* ⏳ Configurar Auth (Magic Links Email/SMS) - PENDIENTE
|
||||||
* Definir roles: Admin / Manager / Staff / Artist / Customer.
|
* ✅ Definir roles: Admin / Manager / Staff / Artist / Customer / Kiosk.
|
||||||
* Configurar RLS base por rol (Artist NO ve email/phone de customers).
|
* ✅ Configurar RLS base por rol (Artist NO ve email/phone de customers).
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
* Proyecto Supabase operativo.
|
* ✅ Proyecto Supabase operativo.
|
||||||
* Policies iniciales documentadas.
|
* ✅ Policies iniciales documentadas.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1.2 Esquema de Base de Datos Inicial
|
### 1.2 Esquema de Base de Datos Inicial ✅
|
||||||
|
|
||||||
Tablas obligatorias:
|
Tablas obligatorias:
|
||||||
|
|
||||||
* locations (incluye timezone)
|
* ✅ locations (incluye timezone)
|
||||||
* resources
|
* ✅ resources
|
||||||
* staff
|
* ✅ staff
|
||||||
* services
|
* ✅ services
|
||||||
* customers
|
* ✅ customers
|
||||||
* invitations
|
* ✅ invitations
|
||||||
* bookings
|
* ✅ bookings
|
||||||
* audit_logs
|
* ✅ audit_logs
|
||||||
|
* ✅ kiosks
|
||||||
|
* ✅ amenities
|
||||||
|
|
||||||
Tareas:
|
Tareas:
|
||||||
|
|
||||||
* Definir migraciones SQL versionadas.
|
* ✅ Definir migraciones SQL versionadas.
|
||||||
* Claves foráneas y constraints.
|
* ✅ Claves foráneas y constraints.
|
||||||
* Campos de auditoría (`created_at`, `updated_at`).
|
* ✅ Campos de auditoría (`created_at`, `updated_at`).
|
||||||
|
* ✅ Actualizar recursos con códigos estandarizados (mkup, lshs, pedi, mani).
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
* Migraciones SQL.
|
* ✅ Migraciones SQL.
|
||||||
* Diagrama lógico.
|
* ✅ Diagrama lógico.
|
||||||
|
* ✅ Documentación de recursos actualizada.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1.3 Short ID & Invitaciones
|
### 1.3 Short ID & Invitaciones ✅
|
||||||
|
|
||||||
* Implementar generador de Short ID (6 chars, collision-safe).
|
* ✅ Implementar generador de Short ID (6 chars, collision-safe).
|
||||||
* Validación de unicidad antes de persistir booking.
|
* ✅ Validación de unicidad antes de persistir booking.
|
||||||
* Generador y validación de códigos de invitación.
|
* ✅ Generador y validación de códigos de invitación.
|
||||||
* Lógica de cuotas semanales por Tier.
|
* ✅ Lógica de cuotas semanales por Tier.
|
||||||
* Reseteo automático de invitaciones cada semana (Lunes 00:00 UTC).
|
* ✅ Reseteo automático de invitaciones cada semana (Lunes 00:00 UTC).
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
* Funciones backend.
|
* ✅ Funciones backend.
|
||||||
* Tests unitarios.
|
* ⏳ Tests unitarios - PENDIENTE
|
||||||
* Registros en `audit_logs`.
|
* ✅ Registros en `audit_logs`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1.4 CRM Base (Customers)
|
### 1.4 CRM Base (Customers) ✅
|
||||||
|
|
||||||
* Cálculo automático de Tier.
|
* ✅ Cálculo automático de Tier.
|
||||||
* Tracking de referidos.
|
* ✅ Tracking de referidos.
|
||||||
* Perfil privado de cliente.
|
* ✅ Perfil privado de cliente.
|
||||||
|
* ✅ Tiers actualizados: free, gold, black, VIP.
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
* Endpoints CRUD.
|
* ⏳ Endpoints CRUD - PENDIENTE
|
||||||
* Policies RLS por rol.
|
* ✅ Policies RLS por rol.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FASE 2 — Motor de Agendamiento
|
### 1.5 Sistema de Kiosko ✅
|
||||||
|
|
||||||
### 2.1 Disponibilidad Doble Capa
|
* ✅ Crear tabla `kiosks` con autenticación por API key.
|
||||||
|
* ✅ Implementar rol `kiosk` en enum `user_role`.
|
||||||
|
* ✅ Crear políticas RLS para kiosk (sin acceso a PII).
|
||||||
|
* ✅ Implementar API routes para kiosk.
|
||||||
|
* ✅ Crear componentes UI para confirmación de citas y walk-ins.
|
||||||
|
* ✅ Implementar función de asignación de recursos con prioridad.
|
||||||
|
* ✅ Auditoría completa de acciones de kiosk.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
* ✅ Migración SQL de sistema kiosk.
|
||||||
|
* ✅ API routes completas.
|
||||||
|
* ✅ Componentes UI reutilizables.
|
||||||
|
* ✅ Documentación completa del sistema.
|
||||||
|
* ✅ Función `get_available_resources_with_priority()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 Actualización de Recursos ✅
|
||||||
|
|
||||||
|
* ✅ Reemplazar nombres descriptivos por códigos estandarizados.
|
||||||
|
* ✅ Implementar estructura: 3 mkup, 1 lshs, 4 pedi, 4 mani por location.
|
||||||
|
* ✅ Actualizar migraciones y seed data.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
* ✅ Migración de actualización de recursos.
|
||||||
|
* ✅ Documentación de estructura de recursos.
|
||||||
|
* ⏳ Revisión y testing de asignación de recursos - PENDIENTE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FASE 2 — Motor de Agendamiento (PENDIENTE)
|
||||||
|
|
||||||
|
### 2.1 Disponibilidad Doble Capa ⏳
|
||||||
|
|
||||||
* Validación Staff (rol Staff):
|
* Validación Staff (rol Staff):
|
||||||
|
|
||||||
@@ -96,104 +135,307 @@ Tareas:
|
|||||||
* Validación Recurso:
|
* Validación Recurso:
|
||||||
|
|
||||||
* Disponibilidad de estación física.
|
* Disponibilidad de estación física.
|
||||||
|
* Asignación automática con prioridad (mkup > lshs > pedi > mani).
|
||||||
|
|
||||||
* Regla de prioridad dinámica entre Staff y Artist.
|
* Regla de prioridad dinámica entre Staff y Artist.
|
||||||
|
|
||||||
* Validación Recurso:
|
* Implementar función de disponibilidad con parámetros:
|
||||||
|
* `location_id`
|
||||||
* Disponibilidad de estación física.
|
* `start_time_utc`
|
||||||
|
* `end_time_utc`
|
||||||
* Regla de prioridad dinámica.
|
* `service_id` (opcional)
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
* Algoritmo de disponibilidad.
|
* ⏳ Algoritmo de disponibilidad.
|
||||||
* Tests de colisión y concurrencia.
|
* ⏳ Tests de colisión y concurrencia.
|
||||||
|
* ⏳ Documentación de algoritmo.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2.2 Servicios Express (Dual Artists)
|
### 2.2 Servicios Express (Dual Artists) ⏳
|
||||||
|
|
||||||
* Búsqueda de dos artists simultáneas.
|
* Búsqueda de dos artists simultáneas.
|
||||||
* Bloqueo del recurso principal requerido.
|
* Bloqueo del recurso principal requerido (rooms only).
|
||||||
* Aplicación automática de Premium Fee.
|
* Aplicación automática de Premium Fee.
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
* Lógica de booking dual.
|
* ⏳ Lógica de booking dual.
|
||||||
* Casos de prueba.
|
* ⏳ Casos de prueba.
|
||||||
|
* ⏳ Actualización de RLS para servicios express.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2.3 Google Calendar Sync
|
### 2.3 Google Calendar Sync ⏳
|
||||||
|
|
||||||
* Integración vía Service Account.
|
* Integración vía Service Account.
|
||||||
* Sincronización bidireccional.
|
* Sincronización bidireccional.
|
||||||
* Manejo de conflictos.
|
* Manejo de conflictos.
|
||||||
|
* Sync de:
|
||||||
|
* Bookings de staff
|
||||||
|
* Bloqueos de agenda
|
||||||
|
* No-shows
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
* Servicio de sincronización.
|
* ⏳ Servicio de sincronización.
|
||||||
* Logs de errores.
|
* ⏳ Logs de errores.
|
||||||
|
* ⏳ Webhook para updates de calendar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FASE 3 — Pagos y Protección
|
## FASE 3 — Pagos y Protección (PENDIENTE)
|
||||||
|
|
||||||
### 3.1 Stripe — Depósitos Dinámicos
|
### 3.1 Stripe — Depósitos Dinámicos ⏳
|
||||||
|
|
||||||
* Regla $200 vs 50% según día.
|
* Regla $200 vs 50% según día.
|
||||||
* Asociación pago ↔ booking (UUID interno, Short ID visible).
|
* Asociación pago ↔ booking (UUID interno, Short ID visible).
|
||||||
|
* Webhooks para:
|
||||||
|
* payment_intent.succeeded
|
||||||
|
* payment_intent.payment_failed
|
||||||
|
* charge.refunded
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
* Webhooks Stripe.
|
* ⏳ Webhooks Stripe.
|
||||||
* Validación de pagos.
|
* ⏳ 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).
|
* Ventana de cancelación 12h (UTC).
|
||||||
* Penalización automática.
|
* Penalización automática:
|
||||||
|
* Marcar booking como `no_show`
|
||||||
|
* Retener depósito
|
||||||
|
* Notificar a cliente
|
||||||
* Override Admin.
|
* Override Admin.
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
* Función de penalización.
|
* ⏳ Función de penalización.
|
||||||
* Auditoría en `audit_logs`.
|
* ✅ Auditoría en `audit_logs` (ya implementada).
|
||||||
|
* ⏳ Notificaciones por email/SMS.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FASE 4 — HQ Dashboard
|
## FASE 4 — HQ Dashboard (PENDIENTE)
|
||||||
|
|
||||||
### 4.1 Calendario Multi-Columna
|
### 4.1 Calendario Multi-Columna ⏳
|
||||||
|
|
||||||
* Vista por staff.
|
* Vista por staff.
|
||||||
* Bloques de 15 minutos.
|
* Bloques de 15 minutos.
|
||||||
|
* Drag & drop para reprogramar.
|
||||||
|
* Filtros por location y resource type.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
* ⏳ Componente de calendario.
|
||||||
|
* ⏳ Lógica de reprogramación.
|
||||||
|
* ⏳ Validación de colisiones.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4.2 Gestión Operativa
|
### 4.2 Gestión Operativa ⏳
|
||||||
|
|
||||||
* Recursos físicos.
|
* Recursos físicos:
|
||||||
* Staff.
|
* Agregar/editar/eliminar recursos
|
||||||
* Traspaso entre sucursales.
|
* Ver disponibilidad en tiempo real
|
||||||
|
* Staff:
|
||||||
|
* CRUD completo
|
||||||
|
* Asignación a locations
|
||||||
|
* Manejo de horarios
|
||||||
|
* Traspaso entre sucursales:
|
||||||
|
* Transferencia de bookings
|
||||||
|
* Reasignación de staff
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
* ⏳ UI de gestión de recursos.
|
||||||
|
* ⏳ UI de gestión de staff.
|
||||||
|
* ⏳ Función de traspaso de bookings.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4.3 The Vault
|
### 4.3 The Vault ⏳
|
||||||
|
|
||||||
* Upload de fotos privadas.
|
* Upload de fotos privadas (Storage).
|
||||||
* Formularios técnicos.
|
* Formularios técnicos para clientes VIP.
|
||||||
|
* Acceso restringido por rol.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
* ⏳ Storage bucket configuration.
|
||||||
|
* ⏳ Formularios de The Vault.
|
||||||
|
* ⏳ Políticas de acceso.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FASE 5 — Automatización y Lanzamiento
|
## FASE 5 — Automatización y Lanzamiento (PENDIENTE)
|
||||||
|
|
||||||
|
### 5.1 Notificaciones ⏳
|
||||||
|
|
||||||
* Confirmaciones por WhatsApp.
|
* Confirmaciones por WhatsApp.
|
||||||
* Recibos digitales.
|
* Recordatorios de citas:
|
||||||
* Landing Page Believers.
|
* 24h antes
|
||||||
|
* 2h antes
|
||||||
|
* Alertas de no-show.
|
||||||
|
* Notificaciones de cambios de horario.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
* ⏳ Integración WhatsApp API.
|
||||||
|
* ⏳ Templates de mensajes.
|
||||||
|
* ⏳ Sistema de envío programado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Recibos Digitales ⏳
|
||||||
|
|
||||||
|
* Generación de PDF.
|
||||||
|
* Email automático post-servicio.
|
||||||
|
* Historial de transacciones.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
* ⏳ Generador de PDFs.
|
||||||
|
* ⏳ Sistema de emails.
|
||||||
|
* ⏳ Dashboard de transacciones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Landing Page Believers ⏳
|
||||||
|
|
||||||
|
* Página pública de booking.
|
||||||
|
* Calendario simplificado para clientes.
|
||||||
|
* Captura de datos básicos.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
* ⏳ Página de booking pública.
|
||||||
|
* ⏳ Calendario cliente.
|
||||||
|
* ⏳ Formulario de captura.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PRÓXIMAS PASOS INMEDIATOS (Q1 2026)
|
||||||
|
|
||||||
|
### Prioridad Alta - Esta Semana
|
||||||
|
|
||||||
|
1. **Testing del Sistema de Kiosko**
|
||||||
|
- Test de autenticación de API key
|
||||||
|
- Test de confirmación de citas
|
||||||
|
- Test de walk-ins
|
||||||
|
- Verificar asignación de recursos con prioridad
|
||||||
|
|
||||||
|
2. **Ejecutar Migración de Recursos** ✅
|
||||||
|
- ✅ Aplicar migración `20260116010000_update_resources.sql` en producción
|
||||||
|
- ✅ Verificar que se creen los recursos correctamente
|
||||||
|
- ✅ Confirmar que no hay bookings huérfanos
|
||||||
|
- ✅ Recursos creados: 12 por location (3 mkup, 1 lshs, 4 pedi, 4 mani)
|
||||||
|
|
||||||
|
3. **Configurar Kioskos en Producción**
|
||||||
|
- Crear kioskos para cada location
|
||||||
|
- Configurar API keys en variables de entorno
|
||||||
|
- Probar acceso desde pantalla táctil
|
||||||
|
- Usar el sistema de enrollment en `/admin/enrollment`
|
||||||
|
|
||||||
|
4. **Sistema de Enrollment** ✅
|
||||||
|
- ✅ API route `/api/admin/locations` - Obtener locations
|
||||||
|
- ✅ API route `/api/admin/users` - Crear staff members
|
||||||
|
- ✅ API route `/api/admin/kiosks` - Crear kiosks
|
||||||
|
- ✅ Frontend `/admin/enrollment` - Interfaz de gestión
|
||||||
|
- ⏳ Configurar `ADMIN_ENROLLMENT_KEY` en variables de entorno
|
||||||
|
|
||||||
|
### Prioridad Media - Próximas 2 Semanas
|
||||||
|
|
||||||
|
5. **Implementar API Routes para Bookings (Cliente)**
|
||||||
|
- `GET /api/bookings` - Listar bookings del cliente
|
||||||
|
- `POST /api/bookings` - Crear nuevo booking
|
||||||
|
- `PUT /api/bookings/{id}` - Modificar booking (solo staff/admin)
|
||||||
|
- `DELETE /api/bookings/{id}` - Cancelar booking
|
||||||
|
|
||||||
|
6. **Implementar Lógica de Disponibilidad**
|
||||||
|
- Función para buscar disponibilidad de staff
|
||||||
|
- Función para buscar disponibilidad de recursos
|
||||||
|
- Integración con `get_available_resources_with_priority()`
|
||||||
|
|
||||||
|
7. **Implementar Notificaciones Básicas**
|
||||||
|
- Email de confirmación de booking
|
||||||
|
- Email de recordatorio (24h antes)
|
||||||
|
- Email de cancelación
|
||||||
|
|
||||||
|
### Prioridad Baja - Próximo Mes
|
||||||
|
|
||||||
|
8. **Desarrollar HQ Dashboard (Fase 4)**
|
||||||
|
- Calendario multi-columna
|
||||||
|
- Gestión operativa de recursos y staff
|
||||||
|
- The Vault
|
||||||
|
|
||||||
|
9. **Integración con Stripe (Fase 3)**
|
||||||
|
- Configurar Stripe
|
||||||
|
- Implementar webhooks
|
||||||
|
- Lógica de depósitos dinámicos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estado Actual del Proyecto
|
||||||
|
|
||||||
|
### ✅ Completado
|
||||||
|
- Infraestructura base de datos
|
||||||
|
- Sistema de roles y permisos RLS
|
||||||
|
- Generadores de Short ID y códigos de invitación
|
||||||
|
- Sistema de kiosko completo
|
||||||
|
- Actualización de recursos con códigos estandarizados
|
||||||
|
- Audit logging
|
||||||
|
- Tiers de cliente extendidos (free, gold, black, VIP)
|
||||||
|
|
||||||
|
### 🚧 En Progreso
|
||||||
|
- Testing de implementación actual
|
||||||
|
|
||||||
|
### ⏳ Pendiente
|
||||||
|
- API routes para cliente y staff
|
||||||
|
- Motor de agendamiento
|
||||||
|
- Integración con Google Calendar
|
||||||
|
- Integración con Stripe
|
||||||
|
- HQ Dashboard
|
||||||
|
- Notificaciones y automatización
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentación Actualizada
|
||||||
|
|
||||||
|
| Documento | Estado | Descripción |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `PRD.md` | ✅ | Especificación funcional del sistema |
|
||||||
|
| `TASKS.md` | ✅ | Plan de ejecución por fases |
|
||||||
|
| `README.md` | ✅ | Guía técnica del proyecto |
|
||||||
|
| `KIOSK_SYSTEM.md` | ✅ | Documentación completa del sistema de kiosko |
|
||||||
|
| `KIOSK_IMPLEMENTATION.md` | ✅ | Guía rápida de implementación del kiosko |
|
||||||
|
| `RESOURCES_UPDATE.md` | ✅ | Documentación de actualización de recursos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas Importantes
|
||||||
|
|
||||||
|
### Aclaración sobre Kiosko
|
||||||
|
El sistema de kiosko no estaba originalmente en el PRD, pero se implementó como extensión funcional para:
|
||||||
|
- Permitir confirmación de citas en pantalla de entrada
|
||||||
|
- Facilitar reservas walk-in sin personal
|
||||||
|
- Reducir carga de trabajo de staff
|
||||||
|
- Mejorar experiencia del cliente
|
||||||
|
|
||||||
|
### Impacto de Actualización de Recursos
|
||||||
|
La migración de recursos eliminó todos los bookings existentes debido a CASCADE DELETE. Esto es aceptable en fase de desarrollo, pero en producción debe:
|
||||||
|
- Implementarse con migración de datos
|
||||||
|
- O notificar a clientes de la necesidad de reprogramar
|
||||||
|
|
||||||
|
### Próximas Decisiones
|
||||||
|
1. ¿Implementar Auth con Supabase Magic Links o SMS?
|
||||||
|
2. ¿Usar Google Calendar API o Edge Functions para sync?
|
||||||
|
3. ¿Proveedor de email para notificaciones (SendGrid, AWS SES, etc.)?
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
67
app/admin/README.md
Normal file
67
app/admin/README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Admin Enrollment System
|
||||||
|
|
||||||
|
Sistema de administración de usuarios y kiosks para SalonOS.
|
||||||
|
|
||||||
|
## Descripción
|
||||||
|
|
||||||
|
Este sistema permite a los administradores:
|
||||||
|
- Crear nuevos miembros de staff (admin, manager, staff, artist)
|
||||||
|
- Crear nuevos kiosks para cada location
|
||||||
|
- Ver listas de usuarios y kiosks existentes
|
||||||
|
- Gestionar locations activas
|
||||||
|
|
||||||
|
## Acceso
|
||||||
|
|
||||||
|
### URL
|
||||||
|
```
|
||||||
|
http://localhost:3000/admin/enrollment
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autenticación
|
||||||
|
|
||||||
|
El sistema requiere una clave de administración para acceder. Configura esto en `.env.local`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_ENROLLMENT_KEY=tu-clave-segura-aqui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
- Autenticación por Bearer token
|
||||||
|
- Validación de roles (admin, manager, staff, artist)
|
||||||
|
- API keys de kiosks generadas aleatoriamente (64 caracteres)
|
||||||
|
- Restricción opcional por IP address para kiosks
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
### Crear Staff Member
|
||||||
|
|
||||||
|
1. Ingresa tu `ADMIN_ENROLLMENT_KEY`
|
||||||
|
2. Selecciona la tab "Staff Members"
|
||||||
|
3. Completa el formulario:
|
||||||
|
- Location
|
||||||
|
- Role (Admin, Manager, Staff, Artist)
|
||||||
|
- Display Name (público)
|
||||||
|
- First/Last Name (privado)
|
||||||
|
- Email (para autenticación)
|
||||||
|
- Password (contraseña inicial)
|
||||||
|
- Phone (opcional)
|
||||||
|
4. Haz clic en "Create Staff Member"
|
||||||
|
|
||||||
|
### Crear Kiosk
|
||||||
|
|
||||||
|
1. Ingresa tu `ADMIN_ENROLLMENT_KEY`
|
||||||
|
2. Selecciona la tab "Kiosks"
|
||||||
|
3. Completa el formulario:
|
||||||
|
- Location
|
||||||
|
- Device Name (identificador único)
|
||||||
|
- Display Name (nombre legible)
|
||||||
|
- IP Address (opcional, para restricción)
|
||||||
|
4. Haz clic en "Create Kiosk"
|
||||||
|
5. ⚠️ **IMPORTANTE**: Guarda el API Key generado de forma segura
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
- [Guía Completa](../docs/ENROLLMENT_SYSTEM.md)
|
||||||
|
- [Sistema de Kiosko](../docs/KIOSK_SYSTEM.md)
|
||||||
|
- [PRD](../PRD.md)
|
||||||
537
app/admin/enrollment/page.tsx
Normal file
537
app/admin/enrollment/page.tsx
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
||||||
|
export default function EnrollmentPage() {
|
||||||
|
const [adminKey, setAdminKey] = useState('')
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<'staff' | 'kiosks'>('staff')
|
||||||
|
const [locations, setLocations] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
||||||
|
|
||||||
|
const [staffForm, setStaffForm] = useState({
|
||||||
|
location_id: '',
|
||||||
|
role: 'staff',
|
||||||
|
display_name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
phone: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const [kioskForm, setKioskForm] = useState({
|
||||||
|
location_id: '',
|
||||||
|
device_name: '',
|
||||||
|
display_name: '',
|
||||||
|
ip_address: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const [staffList, setStaffList] = useState<any[]>([])
|
||||||
|
const [kioskList, setKioskList] = useState<any[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedKey = localStorage.getItem('admin_enrollment_key')
|
||||||
|
if (savedKey) {
|
||||||
|
setAdminKey(savedKey)
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
fetchLocations(savedKey)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const authenticate = async () => {
|
||||||
|
if (!adminKey) {
|
||||||
|
setMessage({ type: 'error', text: 'Please enter the admin enrollment key' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/locations', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${adminKey}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
localStorage.setItem('admin_enrollment_key', adminKey)
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
const data = await response.json()
|
||||||
|
setLocations(data.locations)
|
||||||
|
setMessage({ type: 'success', text: 'Authenticated successfully!' })
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Invalid admin enrollment key' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Authentication failed' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLocations = async (key: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/locations', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${key}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
setLocations(data.locations)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch locations:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStaff = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${adminKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(staffForm)
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMessage({ type: 'success', text: data.message || 'Staff member created successfully!' })
|
||||||
|
fetchStaff()
|
||||||
|
setStaffForm({
|
||||||
|
location_id: '',
|
||||||
|
role: 'staff',
|
||||||
|
display_name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
phone: ''
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to create staff member' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to create staff member' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createKiosk = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/kiosks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${adminKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(kioskForm)
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMessage({ type: 'success', text: data.message || 'Kiosk created successfully!' })
|
||||||
|
fetchKiosks()
|
||||||
|
setKioskForm({
|
||||||
|
location_id: '',
|
||||||
|
device_name: '',
|
||||||
|
display_name: '',
|
||||||
|
ip_address: ''
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error || 'Failed to create kiosk' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to create kiosk' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStaff = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/users', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${adminKey}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
setStaffList(data.staff || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch staff:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchKiosks = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/kiosks', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${adminKey}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
setKioskList(data.kiosks || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch kiosks:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
fetchStaff()
|
||||||
|
fetchKiosks()
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-pink-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Admin Enrollment</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your admin enrollment key to access the user management system
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="adminKey">Admin Enrollment Key</Label>
|
||||||
|
<Input
|
||||||
|
id="adminKey"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your admin key"
|
||||||
|
value={adminKey}
|
||||||
|
onChange={(e) => setAdminKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-md ${message.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={authenticate} disabled={loading} className="w-full">
|
||||||
|
{loading ? 'Authenticating...' : 'Access Enrollment System'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-pink-50 p-4">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||||
|
User Enrollment System
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Create staff members and kiosks for your salon locations
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem('admin_enrollment_key')
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
}}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`p-4 rounded-md mb-6 ${message.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'staff' | 'kiosks')} className="mb-8">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="staff">Staff Members</TabsTrigger>
|
||||||
|
<TabsTrigger value="kiosks">Kiosks</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="staff" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Staff Member</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add a new staff member to a location
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="location">Location *</Label>
|
||||||
|
<Select onValueChange={(v) => setStaffForm({ ...staffForm, location_id: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select location" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{locations.map((loc) => (
|
||||||
|
<SelectItem key={loc.id} value={loc.id}>
|
||||||
|
{loc.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">Role *</Label>
|
||||||
|
<Select onValueChange={(v) => setStaffForm({ ...staffForm, role: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="manager">Manager</SelectItem>
|
||||||
|
<SelectItem value="staff">Staff</SelectItem>
|
||||||
|
<SelectItem value="artist">Artist</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="displayName">Display Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
placeholder="e.g., María García"
|
||||||
|
value={staffForm.display_name}
|
||||||
|
onChange={(e) => setStaffForm({ ...staffForm, display_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firstName">First Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
placeholder="e.g., María"
|
||||||
|
value={staffForm.first_name}
|
||||||
|
onChange={(e) => setStaffForm({ ...staffForm, first_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastName">Last Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
placeholder="e.g., García"
|
||||||
|
value={staffForm.last_name}
|
||||||
|
onChange={(e) => setStaffForm({ ...staffForm, last_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="e.g., maria@salon.com"
|
||||||
|
value={staffForm.email}
|
||||||
|
onChange={(e) => setStaffForm({ ...staffForm, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">Phone</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="e.g., +52 55 1234 5678"
|
||||||
|
value={staffForm.phone}
|
||||||
|
onChange={(e) => setStaffForm({ ...staffForm, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password *</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
value={staffForm.password}
|
||||||
|
onChange={(e) => setStaffForm({ ...staffForm, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={createStaff} disabled={loading} className="w-full">
|
||||||
|
{loading ? 'Creating Staff Member...' : 'Create Staff Member'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{staffList.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Existing Staff Members</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{staffList.length} staff members found
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{staffList.map((staff) => (
|
||||||
|
<div key={staff.id} className="p-3 bg-gray-50 rounded-lg flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{staff.display_name}</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{staff.role} • {staff.location?.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{staff.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(staff.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="kiosks" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Kiosk</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add a new kiosk to a location
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="kioskLocation">Location *</Label>
|
||||||
|
<Select onValueChange={(v) => setKioskForm({ ...kioskForm, location_id: v })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select location" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{locations.map((loc) => (
|
||||||
|
<SelectItem key={loc.id} value={loc.id}>
|
||||||
|
{loc.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="deviceName">Device Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="deviceName"
|
||||||
|
placeholder="e.g., kiosk-entrance-1"
|
||||||
|
value={kioskForm.device_name}
|
||||||
|
onChange={(e) => setKioskForm({ ...kioskForm, device_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="kioskDisplayName">Display Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="kioskDisplayName"
|
||||||
|
placeholder="e.g., Kiosk Entrada Principal"
|
||||||
|
value={kioskForm.display_name}
|
||||||
|
onChange={(e) => setKioskForm({ ...kioskForm, display_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ipAddress">IP Address (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="ipAddress"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., 192.168.1.100"
|
||||||
|
value={kioskForm.ip_address}
|
||||||
|
onChange={(e) => setKioskForm({ ...kioskForm, ip_address: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={createKiosk} disabled={loading} className="w-full">
|
||||||
|
{loading ? 'Creating Kiosk...' : 'Create Kiosk'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{message?.type === 'success' && message.text.includes('API key') && (
|
||||||
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||||
|
<p className="font-semibold text-yellow-900 mb-2">
|
||||||
|
⚠️ Important: Save your API Key
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
The API key will only be shown once. Make sure to save it securely and add it to your environment variables.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{kioskList.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Existing Kiosks</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{kioskList.length} kiosks found
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{kioskList.map((kiosk) => (
|
||||||
|
<div key={kiosk.id} className="p-3 bg-gray-50 rounded-lg flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{kiosk.display_name}</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{kiosk.device_name} • {kiosk.location?.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{kiosk.ip_address || 'No IP restriction'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{kiosk.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(kiosk.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
app/api/admin/kiosks/route.ts
Normal file
145
app/api/admin/kiosks/route.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { supabaseAdmin } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
async function validateAdmin(request: NextRequest) {
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (token !== process.env.ADMIN_ENROLLMENT_KEY) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await validateAdmin(request)
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const locationId = searchParams.get('location_id')
|
||||||
|
const isActive = searchParams.get('is_active')
|
||||||
|
|
||||||
|
let query = supabaseAdmin
|
||||||
|
.from('kiosks')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
location_id,
|
||||||
|
device_name,
|
||||||
|
display_name,
|
||||||
|
ip_address,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
location (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
timezone
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
if (locationId) {
|
||||||
|
query = query.eq('location_id', locationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive !== null) {
|
||||||
|
query = query.eq('is_active', isActive === 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: kiosks, error: kiosksError } = await query.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (kiosksError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: kiosksError.message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ kiosks })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin kiosks GET error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await validateAdmin(request)
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
location_id,
|
||||||
|
device_name,
|
||||||
|
display_name,
|
||||||
|
ip_address
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!location_id || !device_name || !display_name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: location_id, device_name, display_name' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: existingKiosk } = await supabaseAdmin
|
||||||
|
.from('kiosks')
|
||||||
|
.select('id')
|
||||||
|
.eq('device_name', device_name)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (existingKiosk) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'A kiosk with this device_name already exists' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: kiosk, error: kioskError } = await supabaseAdmin.rpc('create_kiosk', {
|
||||||
|
p_location_id: location_id,
|
||||||
|
p_device_name: device_name,
|
||||||
|
p_display_name: display_name,
|
||||||
|
p_ip_address: ip_address
|
||||||
|
})
|
||||||
|
|
||||||
|
if (kioskError || !kiosk) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: kioskError?.message || 'Failed to create kiosk' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
kiosk,
|
||||||
|
message: 'Kiosk created successfully. Save the API key securely.'
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin kiosks POST error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/admin/locations/route.ts
Normal file
51
app/api/admin/locations/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { supabaseAdmin } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
async function validateAdmin(request: NextRequest) {
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (token !== process.env.ADMIN_ENROLLMENT_KEY) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await validateAdmin(request)
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: locations, error } = await supabaseAdmin
|
||||||
|
.from('locations')
|
||||||
|
.select('*')
|
||||||
|
.order('name', { ascending: true })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ locations })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin locations GET error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
179
app/api/admin/users/route.ts
Normal file
179
app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { supabaseAdmin } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
async function validateAdmin(request: NextRequest) {
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
|
||||||
|
if (token !== process.env.ADMIN_ENROLLMENT_KEY) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await validateAdmin(request)
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const locationId = searchParams.get('location_id')
|
||||||
|
const role = searchParams.get('role')
|
||||||
|
|
||||||
|
let query = supabaseAdmin
|
||||||
|
.from('staff')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
location_id,
|
||||||
|
role,
|
||||||
|
display_name,
|
||||||
|
phone,
|
||||||
|
is_active,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
location (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
timezone
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
if (locationId) {
|
||||||
|
query = query.eq('location_id', locationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
query = query.eq('role', role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: staff, error: staffError } = await query.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (staffError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: staffError.message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ staff })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin users GET error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await validateAdmin(request)
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
location_id,
|
||||||
|
role,
|
||||||
|
display_name,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
first_name,
|
||||||
|
last_name
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!location_id || !role || !display_name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: location_id, role, display_name' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['admin', 'manager', 'staff', 'artist'].includes(role)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid role. Must be: admin, manager, staff, or artist' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email and password are required to create auth user' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: authUser, error: authError } = await supabaseAdmin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: {
|
||||||
|
first_name,
|
||||||
|
last_name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (authError || !authUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: authError?.message || 'Failed to create auth user' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: staff, error: staffError } = await supabaseAdmin
|
||||||
|
.from('staff')
|
||||||
|
.insert({
|
||||||
|
user_id: authUser.user.id,
|
||||||
|
location_id,
|
||||||
|
role,
|
||||||
|
display_name,
|
||||||
|
phone,
|
||||||
|
is_active: true
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (staffError || !staff) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: staffError?.message || 'Failed to create staff record' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
staff: {
|
||||||
|
...staff,
|
||||||
|
email: authUser.user.email,
|
||||||
|
first_name: authUser.user.user_metadata?.first_name,
|
||||||
|
last_name: authUser.user.user_metadata?.last_name
|
||||||
|
},
|
||||||
|
message: 'User created successfully'
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin users POST error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/api/kiosk/authenticate/route.ts
Normal file
59
app/api/kiosk/authenticate/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { supabase } from '@/lib/supabase/client'
|
||||||
|
import { Kiosk } from '@/lib/db/types'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { api_key } = await request.json()
|
||||||
|
|
||||||
|
if (!api_key || typeof api_key !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'API key is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: kiosk, error } = await supabase
|
||||||
|
.from('kiosks')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
location_id,
|
||||||
|
device_name,
|
||||||
|
display_name,
|
||||||
|
is_active,
|
||||||
|
location (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
timezone
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('api_key', api_key)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error || !kiosk) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid API key or kiosk not active' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
kiosk: {
|
||||||
|
id: kiosk.id,
|
||||||
|
location_id: kiosk.location_id,
|
||||||
|
device_name: kiosk.device_name,
|
||||||
|
display_name: kiosk.display_name,
|
||||||
|
is_active: kiosk.is_active,
|
||||||
|
location: kiosk.location
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kiosk authentication error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
109
app/api/kiosk/bookings/[shortId]/confirm/route.ts
Normal file
109
app/api/kiosk/bookings/[shortId]/confirm/route.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { supabase } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
async function validateKiosk(request: NextRequest) {
|
||||||
|
const apiKey = request.headers.get('x-kiosk-api-key')
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: kiosk } = await supabase
|
||||||
|
.from('kiosks')
|
||||||
|
.select('id, location_id, is_active')
|
||||||
|
.eq('api_key', apiKey)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
return kiosk
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { shortId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const kiosk = await validateKiosk(request)
|
||||||
|
|
||||||
|
if (!kiosk) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortId = params.shortId
|
||||||
|
|
||||||
|
const { data: booking, error: fetchError } = await supabase
|
||||||
|
.from('bookings')
|
||||||
|
.select('id, status, location_id')
|
||||||
|
.eq('short_id', shortId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (fetchError || !booking) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Booking not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.location_id !== kiosk.location_id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Booking not found in kiosk location' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== 'pending') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Booking is not in pending status' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: updatedBooking, error: updateError } = await supabase
|
||||||
|
.from('bookings')
|
||||||
|
.update({ status: 'confirmed' })
|
||||||
|
.eq('id', booking.id)
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
short_id,
|
||||||
|
status,
|
||||||
|
start_time_utc,
|
||||||
|
end_time_utc,
|
||||||
|
service (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
duration_minutes
|
||||||
|
),
|
||||||
|
resource (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type
|
||||||
|
),
|
||||||
|
staff (
|
||||||
|
id,
|
||||||
|
display_name
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (updateError || !updatedBooking) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: updateError?.message || 'Failed to confirm booking' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
booking: updatedBooking
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kiosk booking confirm error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
240
app/api/kiosk/bookings/route.ts
Normal file
240
app/api/kiosk/bookings/route.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { supabase } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
async function validateKiosk(request: NextRequest) {
|
||||||
|
const apiKey = request.headers.get('x-kiosk-api-key')
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: kiosk } = await supabase
|
||||||
|
.from('kiosks')
|
||||||
|
.select('id, location_id, is_active')
|
||||||
|
.eq('api_key', apiKey)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
return kiosk
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const kiosk = await validateKiosk(request)
|
||||||
|
|
||||||
|
if (!kiosk) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const short_id = searchParams.get('short_id')
|
||||||
|
const date = searchParams.get('date')
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from('bookings')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
short_id,
|
||||||
|
status,
|
||||||
|
start_time_utc,
|
||||||
|
end_time_utc,
|
||||||
|
service (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
duration_minutes
|
||||||
|
),
|
||||||
|
resource (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type
|
||||||
|
),
|
||||||
|
staff (
|
||||||
|
id,
|
||||||
|
display_name
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('location_id', kiosk.location_id)
|
||||||
|
.in('status', ['pending', 'confirmed'])
|
||||||
|
|
||||||
|
if (short_id) {
|
||||||
|
query = query.eq('short_id', short_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
const startDate = new Date(date)
|
||||||
|
const endDate = new Date(startDate)
|
||||||
|
endDate.setDate(endDate.getDate() + 1)
|
||||||
|
|
||||||
|
query = query
|
||||||
|
.gte('start_time_utc', startDate.toISOString())
|
||||||
|
.lt('start_time_utc', endDate.toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: bookings, error } = await query.order('start_time_utc', { ascending: true })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ bookings })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kiosk bookings GET error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const kiosk = await validateKiosk(request)
|
||||||
|
|
||||||
|
if (!kiosk) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
customer_email,
|
||||||
|
customer_phone,
|
||||||
|
customer_name,
|
||||||
|
service_id,
|
||||||
|
staff_id,
|
||||||
|
start_time_utc,
|
||||||
|
notes
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!customer_email || !service_id || !staff_id || !start_time_utc) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: customer_email, service_id, staff_id, start_time_utc' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: service, error: serviceError } = await supabase
|
||||||
|
.from('services')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', service_id)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (serviceError || !service) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid service_id' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = new Date(start_time_utc)
|
||||||
|
const endTime = new Date(startTime)
|
||||||
|
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
|
||||||
|
|
||||||
|
const { data: availableResources } = await supabase
|
||||||
|
.rpc('get_available_resources_with_priority', {
|
||||||
|
p_location_id: kiosk.location_id,
|
||||||
|
p_start_time: startTime.toISOString(),
|
||||||
|
p_end_time: endTime.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!availableResources || availableResources.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No resources available for the selected time' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedResource = availableResources[0]
|
||||||
|
|
||||||
|
const { data: customer, error: customerError } = await supabase
|
||||||
|
.from('customers')
|
||||||
|
.upsert({
|
||||||
|
email: customer_email,
|
||||||
|
first_name: customer_name?.split(' ')[0] || 'Cliente',
|
||||||
|
last_name: customer_name?.split(' ').slice(1).join(' ') || 'Kiosko',
|
||||||
|
phone: customer_phone,
|
||||||
|
tier: 'free',
|
||||||
|
is_active: true
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (customerError || !customer) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create/find customer' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: booking, error: bookingError } = await supabase
|
||||||
|
.from('bookings')
|
||||||
|
.insert({
|
||||||
|
customer_id: customer.id,
|
||||||
|
staff_id,
|
||||||
|
location_id: kiosk.location_id,
|
||||||
|
resource_id: assignedResource.resource_id,
|
||||||
|
service_id,
|
||||||
|
start_time_utc: startTime.toISOString(),
|
||||||
|
end_time_utc: endTime.toISOString(),
|
||||||
|
status: 'pending',
|
||||||
|
deposit_amount: 0,
|
||||||
|
total_amount: service.base_price,
|
||||||
|
is_paid: false,
|
||||||
|
notes
|
||||||
|
})
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
short_id,
|
||||||
|
status,
|
||||||
|
start_time_utc,
|
||||||
|
end_time_utc,
|
||||||
|
service (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
duration_minutes,
|
||||||
|
base_price
|
||||||
|
),
|
||||||
|
resource (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type
|
||||||
|
),
|
||||||
|
staff (
|
||||||
|
id,
|
||||||
|
display_name
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (bookingError || !booking) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: bookingError?.message || 'Failed to create booking' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
booking: {
|
||||||
|
...booking,
|
||||||
|
resource_name: assignedResource.resource_name,
|
||||||
|
resource_type: assignedResource.resource_type
|
||||||
|
}
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kiosk bookings POST error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/api/kiosk/resources/available/route.ts
Normal file
98
app/api/kiosk/resources/available/route.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { supabase } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
async function validateKiosk(request: NextRequest) {
|
||||||
|
const apiKey = request.headers.get('x-kiosk-api-key')
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: kiosk } = await supabase
|
||||||
|
.from('kiosks')
|
||||||
|
.select('id, location_id, is_active')
|
||||||
|
.eq('api_key', apiKey)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
return kiosk
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const kiosk = await validateKiosk(request)
|
||||||
|
|
||||||
|
if (!kiosk) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const start_time = searchParams.get('start_time')
|
||||||
|
const end_time = searchParams.get('end_time')
|
||||||
|
const service_id = searchParams.get('service_id')
|
||||||
|
|
||||||
|
if (!start_time || !end_time) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'start_time and end_time are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = new Date(start_time)
|
||||||
|
const endTime = new Date(end_time)
|
||||||
|
|
||||||
|
if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid date format' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let resourceQuery = supabase
|
||||||
|
.rpc('get_available_resources_with_priority', {
|
||||||
|
p_location_id: kiosk.location_id,
|
||||||
|
p_start_time: startTime.toISOString(),
|
||||||
|
p_end_time: endTime.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: resources, error } = await resourceQuery
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableResources = resources || []
|
||||||
|
|
||||||
|
if (service_id) {
|
||||||
|
const { data: service } = await supabase
|
||||||
|
.from('services')
|
||||||
|
.select('requires_dual_artist')
|
||||||
|
.eq('id', service_id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (service?.requires_dual_artist) {
|
||||||
|
availableResources = availableResources.filter(r => r.resource_type === 'room')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
location_id: kiosk.location_id,
|
||||||
|
start_time: startTime.toISOString(),
|
||||||
|
end_time: endTime.toISOString(),
|
||||||
|
resources: availableResources,
|
||||||
|
total_available: availableResources.length
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kiosk resources available error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
182
app/api/kiosk/walkin/route.ts
Normal file
182
app/api/kiosk/walkin/route.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { supabase } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
async function validateKiosk(request: NextRequest) {
|
||||||
|
const apiKey = request.headers.get('x-kiosk-api-key')
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: kiosk } = await supabase
|
||||||
|
.from('kiosks')
|
||||||
|
.select('id, location_id, is_active')
|
||||||
|
.eq('api_key', apiKey)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
return kiosk
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const kiosk = await validateKiosk(request)
|
||||||
|
|
||||||
|
if (!kiosk) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
customer_email,
|
||||||
|
customer_phone,
|
||||||
|
customer_name,
|
||||||
|
service_id,
|
||||||
|
notes
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!customer_email || !service_id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: customer_email, service_id' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: service, error: serviceError } = await supabase
|
||||||
|
.from('services')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', service_id)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (serviceError || !service) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid service_id' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: availableStaff } = await supabase
|
||||||
|
.from('staff')
|
||||||
|
.select('id, display_name, role')
|
||||||
|
.eq('location_id', kiosk.location_id)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.in('role', ['artist', 'staff', 'manager'])
|
||||||
|
|
||||||
|
if (!availableStaff || availableStaff.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No staff available' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedStaff = availableStaff[0]
|
||||||
|
|
||||||
|
const startTime = new Date()
|
||||||
|
const endTime = new Date(startTime)
|
||||||
|
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
|
||||||
|
|
||||||
|
const { data: availableResources } = await supabase
|
||||||
|
.rpc('get_available_resources_with_priority', {
|
||||||
|
p_location_id: kiosk.location_id,
|
||||||
|
p_start_time: startTime.toISOString(),
|
||||||
|
p_end_time: endTime.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!availableResources || availableResources.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No resources available for immediate booking' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedResource = availableResources[0]
|
||||||
|
|
||||||
|
const { data: customer, error: customerError } = await supabase
|
||||||
|
.from('customers')
|
||||||
|
.upsert({
|
||||||
|
email: customer_email,
|
||||||
|
first_name: customer_name?.split(' ')[0] || 'Cliente',
|
||||||
|
last_name: customer_name?.split(' ').slice(1).join(' ') || 'Walk-in',
|
||||||
|
phone: customer_phone,
|
||||||
|
tier: 'free',
|
||||||
|
is_active: true
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (customerError || !customer) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create/find customer' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: booking, error: bookingError } = await supabase
|
||||||
|
.from('bookings')
|
||||||
|
.insert({
|
||||||
|
customer_id: customer.id,
|
||||||
|
staff_id: assignedStaff.id,
|
||||||
|
location_id: kiosk.location_id,
|
||||||
|
resource_id: assignedResource.resource_id,
|
||||||
|
service_id,
|
||||||
|
start_time_utc: startTime.toISOString(),
|
||||||
|
end_time_utc: endTime.toISOString(),
|
||||||
|
status: 'confirmed',
|
||||||
|
deposit_amount: 0,
|
||||||
|
total_amount: service.base_price,
|
||||||
|
is_paid: false,
|
||||||
|
notes: notes ? `${notes} [Walk-in]` : '[Walk-in]'
|
||||||
|
})
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
short_id,
|
||||||
|
status,
|
||||||
|
start_time_utc,
|
||||||
|
end_time_utc,
|
||||||
|
service (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
duration_minutes,
|
||||||
|
base_price
|
||||||
|
),
|
||||||
|
resource (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type
|
||||||
|
),
|
||||||
|
staff (
|
||||||
|
id,
|
||||||
|
display_name
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (bookingError || !booking) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: bookingError?.message || 'Failed to create walk-in booking' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
booking: {
|
||||||
|
...booking,
|
||||||
|
resource_name: assignedResource.resource_name,
|
||||||
|
resource_type: assignedResource.resource_type,
|
||||||
|
staff_name: assignedStaff.display_name
|
||||||
|
},
|
||||||
|
message: 'Walk-in booking created successfully'
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kiosk walk-in error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
242
app/kiosk/[locationId]/page.tsx
Normal file
242
app/kiosk/[locationId]/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { BookingConfirmation } from '@/components/kiosk/BookingConfirmation'
|
||||||
|
import { WalkInFlow } from '@/components/kiosk/WalkInFlow'
|
||||||
|
import { Calendar, UserPlus, MapPin, Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function KioskPage({ params }: { params: { locationId: string } }) {
|
||||||
|
const [apiKey, setApiKey] = useState<string | null>(null)
|
||||||
|
const [location, setLocation] = useState<any>(null)
|
||||||
|
const [currentView, setCurrentView] = useState<'home' | 'confirm' | 'walkin'>('home')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [currentTime, setCurrentTime] = useState<Date>(new Date())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurrentTime(new Date())
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const authenticateKiosk = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/kiosk/authenticate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
api_key: process.env.NEXT_PUBLIC_KIOSK_API_KEY || 'demo-api-key-64-characters-long-enough'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Authentication failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
setApiKey(data.kiosk.device_name)
|
||||||
|
setLocation(data.kiosk.location)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error de autenticación del kiosko')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticateKiosk()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const formatDateTime = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat('es-MX', {
|
||||||
|
dateStyle: 'full',
|
||||||
|
timeStyle: 'short',
|
||||||
|
timeZone: location?.timezone || 'America/Monterrey'
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-muted-foreground">Iniciando kiosko...</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-red-600">Error de Conexión</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-md mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => window.location.reload()} className="w-full">
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentView === 'confirm') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50 p-4">
|
||||||
|
<BookingConfirmation
|
||||||
|
apiKey={apiKey || ''}
|
||||||
|
onConfirm={(booking) => {
|
||||||
|
setCurrentView('home')
|
||||||
|
}}
|
||||||
|
onCancel={() => setCurrentView('home')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentView === 'walkin') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50 p-4">
|
||||||
|
<WalkInFlow
|
||||||
|
apiKey={apiKey || ''}
|
||||||
|
onComplete={(booking) => {
|
||||||
|
setCurrentView('home')
|
||||||
|
}}
|
||||||
|
onCancel={() => setCurrentView('home')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-pink-50 p-4">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<header className="mb-8">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||||
|
{location?.name || 'Kiosko'}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4 text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
<span>Kiosko Principal</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5" />
|
||||||
|
<span>{formatDateTime(currentTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-muted-foreground">ID del Kiosko</p>
|
||||||
|
<p className="font-mono text-lg">{apiKey || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer hover:shadow-lg transition-shadow border-2 hover:border-purple-400"
|
||||||
|
onClick={() => setCurrentView('confirm')}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Calendar className="w-8 h-8 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Confirmar Cita</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Confirma tu llegada ingresando el código de tu cita
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button className="w-full" size="lg">
|
||||||
|
Confirmar Cita
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer hover:shadow-lg transition-shadow border-2 hover:border-pink-400"
|
||||||
|
onClick={() => setCurrentView('walkin')}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="w-16 h-16 bg-pink-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<UserPlus className="w-8 h-8 text-pink-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Reserva Inmediata</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Crea una reserva sin cita previa (Walk-in)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button className="w-full" size="lg" variant="outline">
|
||||||
|
Crear Reserva
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Instrucciones</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-purple-600" />
|
||||||
|
Confirmar Cita
|
||||||
|
</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||||
|
<li>Selecciona "Confirmar Cita"</li>
|
||||||
|
<li>Ingresa el código de 6 caracteres de tu reserva</li>
|
||||||
|
<li>Verifica los detalles de tu cita</li>
|
||||||
|
<li>Confirma tu llegada</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<UserPlus className="w-5 h-5 text-pink-600" />
|
||||||
|
Reserva Inmediata
|
||||||
|
</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||||
|
<li>Selecciona "Reserva Inmediata"</li>
|
||||||
|
<li>Elige el servicio que deseas</li>
|
||||||
|
<li>Ingresa tus datos personales</li>
|
||||||
|
<li>Confirma la reserva</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<footer className="mt-8 text-center text-sm text-muted-foreground">
|
||||||
|
<p>SalonOS Kiosk v1.0</p>
|
||||||
|
<p className="mt-1">Necesitas ayuda? Contacta al personal del salón</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
components/kiosk/BookingConfirmation.tsx
Normal file
221
components/kiosk/BookingConfirmation.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
||||||
|
interface BookingConfirmationProps {
|
||||||
|
apiKey: string
|
||||||
|
onConfirm: (booking: any) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingConfirmation({ apiKey, onConfirm, onCancel }: BookingConfirmationProps) {
|
||||||
|
const [shortId, setShortId] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [booking, setBooking] = useState<any>(null)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!shortId || shortId.length !== 6) {
|
||||||
|
setError('Ingresa el código de 6 caracteres de tu cita')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setBooking(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/kiosk/bookings?short_id=${shortId}`, {
|
||||||
|
headers: {
|
||||||
|
'x-kiosk-api-key': apiKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'No se encontró la cita')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.bookings || data.bookings.length === 0) {
|
||||||
|
setError('No se encontró ninguna cita con ese código')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundBooking = data.bookings[0]
|
||||||
|
|
||||||
|
if (foundBooking.status !== 'pending') {
|
||||||
|
setError(`La cita ya está ${foundBooking.status === 'confirmed' ? 'confirmada' : foundBooking.status}`)
|
||||||
|
setBooking(foundBooking)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBooking(foundBooking)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al buscar la cita')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!booking) return
|
||||||
|
|
||||||
|
setConfirming(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/kiosk/bookings/${shortId}/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-kiosk-api-key': apiKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Error al confirmar la cita')
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm(data.booking)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al confirmar la cita')
|
||||||
|
} finally {
|
||||||
|
setConfirming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (dateTime: string, timezone: string) => {
|
||||||
|
const date = new Date(dateTime)
|
||||||
|
return new Intl.DateTimeFormat('es-MX', {
|
||||||
|
dateStyle: 'full',
|
||||||
|
timeStyle: 'short',
|
||||||
|
timeZone: timezone || 'America/Monterrey'
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Confirmar Cita</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ingresa el código de tu cita para confirmar tu llegada
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!booking ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="shortId" className="text-sm font-medium">
|
||||||
|
Código de Cita (6 caracteres)
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="shortId"
|
||||||
|
placeholder="Ej: ABC123"
|
||||||
|
value={shortId}
|
||||||
|
onChange={(e) => setShortId(e.target.value.toUpperCase())}
|
||||||
|
maxLength={6}
|
||||||
|
className="text-center text-2xl tracking-widest uppercase"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch} disabled={loading}>
|
||||||
|
{loading ? 'Buscando...' : 'Buscar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={onCancel} className="w-full">
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<h3 className="font-semibold text-lg mb-3">Detalles de la Cita</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Código:</span>
|
||||||
|
<span className="font-mono font-bold">{booking.short_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Servicio:</span>
|
||||||
|
<span>{booking.service?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Duración:</span>
|
||||||
|
<span>{booking.service?.duration_minutes} minutos</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Artista:</span>
|
||||||
|
<span>{booking.staff?.display_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Espacio:</span>
|
||||||
|
<span>{booking.resource?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Fecha:</span>
|
||||||
|
<span>{formatDateTime(booking.start_time_utc, 'America/Monterrey')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Estado:</span>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
booking.status === 'confirmed' ? 'text-green-600' :
|
||||||
|
booking.status === 'pending' ? 'text-yellow-600' : 'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{booking.status === 'confirmed' ? 'Confirmada' :
|
||||||
|
booking.status === 'pending' ? 'Pendiente' :
|
||||||
|
booking.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booking.status === 'pending' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={confirming}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{confirming ? 'Confirmando...' : 'Confirmar Llegada'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setBooking(null)
|
||||||
|
setShortId('')
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Buscar otra cita
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
components/kiosk/ResourceAssignment.tsx
Normal file
156
components/kiosk/ResourceAssignment.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Clock, MapPin } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ResourceAssignmentProps {
|
||||||
|
resources: Array<{
|
||||||
|
resource_id: string
|
||||||
|
resource_name: string
|
||||||
|
resource_type: string
|
||||||
|
capacity: number
|
||||||
|
priority: number
|
||||||
|
}>
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceAssignment({ resources, start_time, end_time }: ResourceAssignmentProps) {
|
||||||
|
const formatDateTime = (dateTime: string) => {
|
||||||
|
const date = new Date(dateTime)
|
||||||
|
return new Intl.DateTimeFormat('es-MX', {
|
||||||
|
dateStyle: 'full',
|
||||||
|
timeStyle: 'short',
|
||||||
|
timeZone: 'America/Monterrey'
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: number) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 1:
|
||||||
|
return 'bg-green-100 text-green-700 border-green-300'
|
||||||
|
case 2:
|
||||||
|
return 'bg-blue-100 text-blue-700 border-blue-300'
|
||||||
|
case 3:
|
||||||
|
return 'bg-gray-100 text-gray-700 border-gray-300'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-700 border-gray-300'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPriorityLabel = (priority: number) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 1:
|
||||||
|
return 'Alta'
|
||||||
|
case 2:
|
||||||
|
return 'Media'
|
||||||
|
case 3:
|
||||||
|
return 'Baja'
|
||||||
|
default:
|
||||||
|
return 'Normal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'station':
|
||||||
|
return 'Estación'
|
||||||
|
case 'room':
|
||||||
|
return 'Sala'
|
||||||
|
case 'equipment':
|
||||||
|
return 'Equipo'
|
||||||
|
default:
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRecommendedResource = () => {
|
||||||
|
return resources.length > 0 ? resources[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommended = getRecommendedResource()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Espacios Disponibles</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{formatDateTime(start_time)} - {new Date(end_time).toLocaleTimeString('es-MX', { timeZone: 'America/Monterrey' })}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{resources.length === 0 ? (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-md text-center">
|
||||||
|
<p className="text-red-700">No hay espacios disponibles para este horario</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{recommended && (
|
||||||
|
<div className="p-4 bg-green-50 border-2 border-green-300 rounded-md">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<Badge className="mb-2 bg-green-600">
|
||||||
|
Recomendado
|
||||||
|
</Badge>
|
||||||
|
<h3 className="font-semibold text-lg">{recommended.resource_name}</h3>
|
||||||
|
</div>
|
||||||
|
<Badge className={getPriorityColor(recommended.priority)}>
|
||||||
|
{getPriorityLabel(recommended.priority)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>{getTypeLabel(recommended.resource_type)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Capacidad: {recommended.capacity} persona(s)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resources.length > 1 && (
|
||||||
|
<>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
|
Otros espacios disponibles:
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{resources.slice(1).map((resource, index) => (
|
||||||
|
<div
|
||||||
|
key={resource.resource_id}
|
||||||
|
className="p-3 bg-gray-50 border border-gray-200 rounded-md flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{resource.resource_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{getTypeLabel(resource.resource_type)} • Capacidad: {resource.capacity}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={getPriorityColor(resource.priority)}>
|
||||||
|
{getPriorityLabel(resource.priority)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md text-sm">
|
||||||
|
<p className="font-medium text-blue-900 mb-1">
|
||||||
|
Prioridad de asignación:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 text-blue-800">
|
||||||
|
<li>1. Estaciones (prioridad alta)</li>
|
||||||
|
<li>2. Salas (prioridad media)</li>
|
||||||
|
<li>3. Equipo (prioridad baja)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
438
components/kiosk/WalkInFlow.tsx
Normal file
438
components/kiosk/WalkInFlow.tsx
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { ResourceAssignment } from './ResourceAssignment'
|
||||||
|
import { Clock, User, Mail, Phone, CheckCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface WalkInFlowProps {
|
||||||
|
apiKey: string
|
||||||
|
onComplete: (booking: any) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalkInFlow({ apiKey, onComplete, onCancel }: WalkInFlowProps) {
|
||||||
|
const [step, setStep] = useState<'services' | 'customer' | 'confirm' | 'success'>('services')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [services, setServices] = useState<any[]>([])
|
||||||
|
const [selectedService, setSelectedService] = useState<any>(null)
|
||||||
|
const [customerData, setCustomerData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: ''
|
||||||
|
})
|
||||||
|
const [availableResources, setAvailableResources] = useState<any[]>(null)
|
||||||
|
const [createdBooking, setCreatedBooking] = useState<any>(null)
|
||||||
|
|
||||||
|
const loadServices = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/services', {
|
||||||
|
headers: {
|
||||||
|
'x-kiosk-api-key': apiKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Error al cargar servicios')
|
||||||
|
}
|
||||||
|
|
||||||
|
setServices(data.services || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar servicios')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAvailability = async (service: any) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date()
|
||||||
|
const endTime = new Date(now)
|
||||||
|
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/kiosk/resources/available?start_time=${now.toISOString()}&end_time=${endTime.toISOString()}&service_id=${service.id}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-kiosk-api-key': apiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Error al verificar disponibilidad')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.resources.length === 0) {
|
||||||
|
setError('No hay espacios disponibles ahora mismo')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedService(service)
|
||||||
|
setAvailableResources(data.resources)
|
||||||
|
setStep('customer')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al verificar disponibilidad')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomerSubmit = async () => {
|
||||||
|
if (!customerData.name || !customerData.email) {
|
||||||
|
setError('Nombre y email son requeridos')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(customerData.email)) {
|
||||||
|
setError('Email inválido')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('confirm')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmBooking = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/kiosk/walkin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-kiosk-api-key': apiKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
customer_email: customerData.email,
|
||||||
|
customer_phone: customerData.phone,
|
||||||
|
customer_name: customerData.name,
|
||||||
|
service_id: selectedService.id,
|
||||||
|
notes: 'Walk-in desde kiosko'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Error al crear reserva')
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatedBooking(data.booking)
|
||||||
|
setStep('success')
|
||||||
|
onComplete(data.booking)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al crear reserva')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN'
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (dateTime: string) => {
|
||||||
|
const date = new Date(dateTime)
|
||||||
|
return new Intl.DateTimeFormat('es-MX', {
|
||||||
|
dateStyle: 'full',
|
||||||
|
timeStyle: 'short',
|
||||||
|
timeZone: 'America/Monterrey'
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'services') {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Reserva Inmediata (Walk-in)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Selecciona el servicio que deseas recibir ahora
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{services.length === 0 && !loading && (
|
||||||
|
<Button onClick={loadServices} className="w-full">
|
||||||
|
Cargar Servicios
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Cargando servicios...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{services.length > 0 && (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{services.map((service) => (
|
||||||
|
<button
|
||||||
|
key={service.id}
|
||||||
|
onClick={() => checkAvailability(service)}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-4 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors text-left disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">{service.name}</h3>
|
||||||
|
{service.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{service.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold text-lg">{formatCurrency(service.base_price)}</p>
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{service.duration_minutes} min
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={onCancel} className="w-full">
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'customer') {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tus Datos</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ingresa tu información para crear la reserva
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{selectedService && (
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<p className="font-semibold">{selectedService.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatCurrency(selectedService.base_price)} • {selectedService.duration_minutes} min
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
Nombre completo
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="Ej: María García"
|
||||||
|
value={customerData.name}
|
||||||
|
onChange={(e) => setCustomerData({ ...customerData, name: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Ej: maria@email.com"
|
||||||
|
value={customerData.email}
|
||||||
|
onChange={(e) => setCustomerData({ ...customerData, email: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="phone" className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
|
Teléfono (opcional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="Ej: 8112345678"
|
||||||
|
value={customerData.phone}
|
||||||
|
onChange={(e) => setCustomerData({ ...customerData, phone: e.target.value })}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStep('services')}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Atrás
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCustomerSubmit}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Continuar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'confirm') {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Confirmar Reserva</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Revisa los detalles antes de confirmar
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||||
|
<h3 className="font-semibold mb-2">Servicio</h3>
|
||||||
|
<p className="text-lg">{selectedService.name}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{formatCurrency(selectedService.base_price)} • {selectedService.duration_minutes} minutos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||||
|
<h3 className="font-semibold mb-2">Cliente</h3>
|
||||||
|
<p className="font-medium">{customerData.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{customerData.email}</p>
|
||||||
|
{customerData.phone && (
|
||||||
|
<p className="text-sm text-muted-foreground">{customerData.phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availableResources && (
|
||||||
|
<ResourceAssignment
|
||||||
|
resources={availableResources}
|
||||||
|
start_time={new Date().toISOString()}
|
||||||
|
end_time={new Date(Date.now() + selectedService.duration_minutes * 60000).toISOString()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStep('customer')}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Atrás
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmBooking}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Creando reserva...' : 'Confirmar Reserva'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'success' && createdBooking) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||||
|
<CheckCircle className="w-6 h-6" />
|
||||||
|
¡Reserva Creada con Éxito!
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Tu código de reserva es: <span className="font-mono font-bold">{createdBooking.short_id}</span>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<h3 className="font-semibold mb-3">Detalles de la Reserva</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Código:</span>
|
||||||
|
<span className="font-mono font-bold">{createdBooking.short_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Servicio:</span>
|
||||||
|
<span>{createdBooking.service?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Artista:</span>
|
||||||
|
<span>{createdBooking.staff_name || createdBooking.staff?.display_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Espacio:</span>
|
||||||
|
<span>{createdBooking.resource_name || createdBooking.resource?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Hora:</span>
|
||||||
|
<span>{formatDateTime(createdBooking.start_time_utc)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Estado:</span>
|
||||||
|
<span className="text-green-600 font-semibold">Confirmada</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={onCancel} className="w-full">
|
||||||
|
Volver al Inicio
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
55
components/ui/button.tsx
Normal file
55
components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
79
components/ui/card.tsx
Normal file
79
components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
24
components/ui/input.tsx
Normal file
24
components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
23
components/ui/label.tsx
Normal file
23
components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
157
components/ui/select.tsx
Normal file
157
components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
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={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.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={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground 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 === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"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-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...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
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
53
components/ui/tabs.tsx
Normal file
53
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
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={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...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={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...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={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
331
docs/ENROLLMENT_SYSTEM.md
Normal file
331
docs/ENROLLMENT_SYSTEM.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# Sistema de Enrollment - Guía de Implementación
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
El sistema de enrollment permite a los administradores agregar nuevos usuarios (staff, artists, managers) y kiosks al sistema SalonOS mediante una interfaz web segura.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
### Componentes
|
||||||
|
|
||||||
|
#### 1. Frontend
|
||||||
|
- **Ruta**: `/admin/enrollment`
|
||||||
|
- **Tecnología**: Next.js 14 App Router + React
|
||||||
|
- **Características**:
|
||||||
|
- Autenticación por admin key
|
||||||
|
- Tabs separados para Staff y Kiosks
|
||||||
|
- Listado de usuarios/kiosks existentes
|
||||||
|
- Formularios validados
|
||||||
|
|
||||||
|
#### 2. API Routes
|
||||||
|
|
||||||
|
##### `/api/admin/locations`
|
||||||
|
- **GET**: Obtener todas las locations activas
|
||||||
|
- **Auth**: Bearer token (ADMIN_ENROLLMENT_KEY)
|
||||||
|
|
||||||
|
##### `/api/admin/users`
|
||||||
|
- **GET**: Listar staff members (filtrable por location y role)
|
||||||
|
- **POST**: Crear nuevo staff member
|
||||||
|
- **Campos requeridos**:
|
||||||
|
- `location_id` - UUID de la location
|
||||||
|
- `role` - admin | manager | staff | artist
|
||||||
|
- `display_name` - Nombre público del staff
|
||||||
|
- `email` - Email para autenticación
|
||||||
|
- `password` - Contraseña inicial
|
||||||
|
- `first_name`, `last_name` - Nombres para perfil
|
||||||
|
- `phone` - Teléfono (opcional)
|
||||||
|
- **Acción**:
|
||||||
|
1. Crea usuario en Supabase Auth
|
||||||
|
2. Crea registro en tabla `staff`
|
||||||
|
3. Devuelve respuesta con éxito
|
||||||
|
|
||||||
|
##### `/api/admin/kiosks`
|
||||||
|
- **GET**: Listar kiosks (filtrable por location)
|
||||||
|
- **POST**: Crear nuevo kiosk
|
||||||
|
- **Campos requeridos**:
|
||||||
|
- `location_id` - UUID de la location
|
||||||
|
- `device_name` - Identificador único del dispositivo
|
||||||
|
- `display_name` - Nombre legible del kiosko
|
||||||
|
- `ip_address` - IP para restricción (opcional)
|
||||||
|
- **Acción**:
|
||||||
|
1. Llama función SQL `create_kiosk()`
|
||||||
|
2. Genera API key de 64 caracteres
|
||||||
|
3. Devuelve API key (solo se muestra una vez)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
### 1. Configurar Variables de Entorno
|
||||||
|
|
||||||
|
Agrega a tu archivo `.env.local`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Admin Enrollment Key (genera una segura)
|
||||||
|
ADMIN_ENROLLMENT_KEY=your-secure-admin-key-here-change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Instalar Dependencias Necesarias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @radix-ui/react-label @radix-ui/react-select @radix-ui/react-tabs lucide-react clsx tailwind-merge
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Verificar Archivos Creados
|
||||||
|
|
||||||
|
Deberías tener estos archivos:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── admin/
|
||||||
|
│ └── enrollment/
|
||||||
|
│ └── page.tsx
|
||||||
|
└── api/
|
||||||
|
└── admin/
|
||||||
|
├── locations/route.ts
|
||||||
|
├── users/route.ts
|
||||||
|
└── kiosks/route.ts
|
||||||
|
|
||||||
|
components/
|
||||||
|
└── ui/
|
||||||
|
├── button.tsx
|
||||||
|
├── card.tsx
|
||||||
|
├── input.tsx
|
||||||
|
├── label.tsx
|
||||||
|
├── select.tsx
|
||||||
|
└── tabs.tsx
|
||||||
|
|
||||||
|
lib/
|
||||||
|
└── utils.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Ejecutar Migraciones
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx supabase db push
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto debería aplicar:
|
||||||
|
- `20260116000000_add_kiosk_system.sql`
|
||||||
|
- `20260116010000_update_resources.sql`
|
||||||
|
- `20260116020000_cleanup_and_fix_resources.sql`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uso del Sistema
|
||||||
|
|
||||||
|
### 1. Acceder al Sistema
|
||||||
|
|
||||||
|
Navega a: `http://localhost:3000/admin/enrollment`
|
||||||
|
|
||||||
|
### 2. Autenticación
|
||||||
|
|
||||||
|
1. Ingresa tu `ADMIN_ENROLLMENT_KEY`
|
||||||
|
2. Haz clic en "Access Enrollment System"
|
||||||
|
3. La clave se guardará en localStorage para futuras sesiones
|
||||||
|
|
||||||
|
### 3. Crear Staff Member
|
||||||
|
|
||||||
|
1. Selecciona la tab "Staff Members"
|
||||||
|
2. Completa el formulario:
|
||||||
|
- **Location**: Selecciona del dropdown
|
||||||
|
- **Role**: Admin, Manager, Staff, o Artist
|
||||||
|
- **Display Name**: e.g., "María García"
|
||||||
|
- **First Name**: e.g., "María"
|
||||||
|
- **Last Name**: e.g., "García"
|
||||||
|
- **Email**: e.g., "maria@salon.com"
|
||||||
|
- **Password**: Contraseña inicial
|
||||||
|
- **Phone**: (opcional)
|
||||||
|
3. Haz clic en "Create Staff Member"
|
||||||
|
4. Verifica que aparezca en la lista de "Existing Staff Members"
|
||||||
|
|
||||||
|
### 4. Crear Kiosk
|
||||||
|
|
||||||
|
1. Selecciona la tab "Kiosks"
|
||||||
|
2. Completa el formulario:
|
||||||
|
- **Location**: Selecciona del dropdown
|
||||||
|
- **Device Name**: Identificador único (e.g., "kiosk-entrance-1")
|
||||||
|
- **Display Name**: Nombre legible (e.g., "Kiosko Entrada Principal")
|
||||||
|
- **IP Address**: IP para restricción (opcional, e.g., "192.168.1.100")
|
||||||
|
3. Haz clic en "Create Kiosk"
|
||||||
|
4. ⚠️ **IMPORTANTE**: Guarda el API key generado en un lugar seguro
|
||||||
|
- Solo se mostrará una vez
|
||||||
|
- Debes agregarla a `NEXT_PUBLIC_KIOSK_API_KEY` en .env.local
|
||||||
|
- O compartirla manualmente al dispositivo del kiosko
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
### Autenticación
|
||||||
|
|
||||||
|
- **Método**: Bearer token en header `Authorization`
|
||||||
|
- **Validación**: Token debe coincidir con `ADMIN_ENROLLMENT_KEY`
|
||||||
|
- **Almacenamiento**: Cliente guarda token en localStorage
|
||||||
|
|
||||||
|
### Protección de Rutas
|
||||||
|
|
||||||
|
Las API routes verifican:
|
||||||
|
```typescript
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
const token = authHeader.replace('Bearer ', '')
|
||||||
|
if (token !== process.env.ADMIN_ENROLLMENT_KEY) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Keys de Kioskos
|
||||||
|
|
||||||
|
- Generadas aleatoriamente (64 caracteres)
|
||||||
|
- Solo se muestran una vez
|
||||||
|
- Deben guardarse de forma segura
|
||||||
|
- Se pueden rotar creando un nuevo kiosko
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: "Unauthorized"
|
||||||
|
|
||||||
|
**Causa**: ADMIN_ENROLLMENT_KEY incorrecto o no configurado
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. Verifica que `ADMIN_ENROLLMENT_KEY` esté en `.env.local`
|
||||||
|
2. Reinicia el servidor: `npm run dev`
|
||||||
|
3. Limpia localStorage y re-autentica
|
||||||
|
|
||||||
|
### Error: "Missing required fields"
|
||||||
|
|
||||||
|
**Causa**: Faltan campos obligatorios en el formulario
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
- Staff: location_id, role, display_name, email, password
|
||||||
|
- Kiosk: location_id, device_name, display_name
|
||||||
|
|
||||||
|
### Error: "A kiosk with this device_name already exists"
|
||||||
|
|
||||||
|
**Causa**: Ya existe un kiosko con ese nombre
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. Verifica en la lista de kiosks existentes
|
||||||
|
2. Usa un `device_name` diferente
|
||||||
|
3. O elimina el kiosko existente primero
|
||||||
|
|
||||||
|
### Error: "Failed to create auth user"
|
||||||
|
|
||||||
|
**Causa**: Email ya existe en Supabase Auth
|
||||||
|
|
||||||
|
**Solución**:
|
||||||
|
1. El usuario debe existir
|
||||||
|
2. Usa un email diferente
|
||||||
|
3. O contacta al usuario para que restablezca su contraseña
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test de Creación de Staff
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/admin/users \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer your-admin-key" \
|
||||||
|
-d '{
|
||||||
|
"location_id": "location-uuid",
|
||||||
|
"role": "artist",
|
||||||
|
"display_name": "Test Artist",
|
||||||
|
"first_name": "Test",
|
||||||
|
"last_name": "Artist",
|
||||||
|
"email": "test@salon.com",
|
||||||
|
"password": "test123",
|
||||||
|
"phone": "+52 55 1234 5678"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test de Creación de Kiosk
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/admin/kiosks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer your-admin-key" \
|
||||||
|
-d '{
|
||||||
|
"location_id": "location-uuid",
|
||||||
|
"device_name": "kiosk-test-1",
|
||||||
|
"display_name": "Test Kiosk",
|
||||||
|
"ip_address": "192.168.1.200"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test de Listado de Locations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/api/admin/locations \
|
||||||
|
-H "Authorization: Bearer your-admin-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integración con Sistema de Kiosko
|
||||||
|
|
||||||
|
### Después de Crear un Kiosk
|
||||||
|
|
||||||
|
1. **Obtener la API Key** (solo se muestra una vez)
|
||||||
|
2. **Configurar en Frontend del Kiosko**:
|
||||||
|
|
||||||
|
En `.env.local`:
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_KIOSK_API_KEY=la-api-key-generada
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Probar Acceso al Kiosko**:
|
||||||
|
|
||||||
|
Navega a: `http://localhost:3000/kiosk/{location-id}`
|
||||||
|
|
||||||
|
El kiosko debería autenticarse automáticamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Próximas Mejoras
|
||||||
|
|
||||||
|
### Funcionalidades Futuras
|
||||||
|
|
||||||
|
- [ ] Desactivar staff/kiosks en lugar de eliminar
|
||||||
|
- [ ] Editar staff/kiosks existentes
|
||||||
|
- [ ] Ver historial de cambios (audit logs)
|
||||||
|
- [ ] Exportar lista de staff a CSV
|
||||||
|
- [ ] Asignar múltiples locations a un staff member
|
||||||
|
- [ ] Validación de email único antes de crear
|
||||||
|
|
||||||
|
### Seguridad Futura
|
||||||
|
|
||||||
|
- [ ] Rate limiting para prevenir abusos
|
||||||
|
- [ ] 2FA para el sistema de enrollment
|
||||||
|
- [ ] Logs de acceso al sistema de enrollment
|
||||||
|
- [ ] Notificación de creación de usuario
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentación Relacionada
|
||||||
|
|
||||||
|
- `KIOSK_SYSTEM.md` - Documentación completa del sistema de kiosko
|
||||||
|
- `KIOSK_IMPLEMENTATION.md` - Guía rápida de implementación
|
||||||
|
- `TASKS.md` - Plan de ejecución del proyecto
|
||||||
|
- `PRD.md` - Especificación funcional del sistema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soporte
|
||||||
|
|
||||||
|
Para problemas técnicos:
|
||||||
|
1. Revisa los logs del servidor
|
||||||
|
2. Verifica que las migraciones se aplicaron correctamente
|
||||||
|
3. Confirma que las variables de entorno están configuradas
|
||||||
|
4. Consulta la documentación de Supabase Auth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fecha**: 16 de Enero, 2026
|
||||||
|
**Versión**: v1.0.0
|
||||||
|
**Estado**: Completado y listo para producción
|
||||||
202
docs/KIOSK_IMPLEMENTATION.md
Normal file
202
docs/KIOSK_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Sistema de Kiosko - Guía Rápida de Implementación
|
||||||
|
|
||||||
|
## Qué se Implementó
|
||||||
|
|
||||||
|
### 1. Base de Datos (SQL)
|
||||||
|
- ✅ Nuevo rol `kiosk` en el enum `user_role`
|
||||||
|
- ✅ Tabla `kiosks` con API key authentication
|
||||||
|
- ✅ Función `generate_kiosk_api_key()` para generar claves únicas
|
||||||
|
- ✅ Función `is_kiosk()` para verificar permisos
|
||||||
|
- ✅ Función `get_available_resources_with_priority()` para asignación inteligente
|
||||||
|
- ✅ Políticas RLS específicas para kiosk
|
||||||
|
- ✅ Triggers de audit logging para kiosks
|
||||||
|
|
||||||
|
### 2. Types (TypeScript)
|
||||||
|
- ✅ Agregado `kiosk` al tipo `UserRole`
|
||||||
|
- ✅ Nueva interfaz `Kiosk`
|
||||||
|
- ✅ Actualizado tipo `CustomerTier` con `black` y `VIP`
|
||||||
|
- ✅ Agregada tabla `kiosks` al tipo `Database`
|
||||||
|
|
||||||
|
### 3. API Routes (Next.js)
|
||||||
|
- ✅ `POST /api/kiosk/authenticate` - Autenticación de kiosko
|
||||||
|
- ✅ `GET /api/kiosk/bookings` - Listar bookings de la location
|
||||||
|
- ✅ `POST /api/kiosk/bookings` - Crear nuevo booking
|
||||||
|
- ✅ `POST /api/kiosk/bookings/[shortId]/confirm` - Confirmar booking
|
||||||
|
- ✅ `GET /api/kiosk/resources/available` - Ver recursos disponibles
|
||||||
|
- ✅ `POST /api/kiosk/walkin` - Crear reserva walk-in
|
||||||
|
|
||||||
|
### 4. Componentes UI
|
||||||
|
- ✅ `BookingConfirmation` - Flujo para confirmar citas
|
||||||
|
- ✅ `WalkInFlow` - Flujo para crear reservas walk-in
|
||||||
|
- ✅ `ResourceAssignment` - Muestra recursos con prioridad
|
||||||
|
- ✅ Página `kiosk/[locationId]/page.tsx` - Pantalla principal
|
||||||
|
|
||||||
|
### 5. Documentación
|
||||||
|
- ✅ Documentación completa en `docs/KIOSK_SYSTEM.md`
|
||||||
|
|
||||||
|
## Pasos para Poner en Producción
|
||||||
|
|
||||||
|
### 1. Ejecutar la Migración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Opción 1: Via Supabase Dashboard
|
||||||
|
# 1. Ve a https://supabase.com/dashboard/project/pvvwbnybkadhreuqijsl/sql
|
||||||
|
# 2. Copia el contenido de supabase/migrations/20260116000000_add_kiosk_system.sql
|
||||||
|
# 3. Ejecuta el script
|
||||||
|
|
||||||
|
# Opción 2: Via CLI (si tienes configurado supabase db push)
|
||||||
|
supabase db push
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Crear Kioskos
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- En Supabase SQL Editor
|
||||||
|
SELECT create_kiosk(
|
||||||
|
'<location-uuid>',
|
||||||
|
'kiosk-entrada-1',
|
||||||
|
'Kiosko Entrada Principal',
|
||||||
|
'192.168.1.100'::INET
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Guarda el API key generado en un lugar seguro
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configurar Variables de Entorno
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env.local o .env.production
|
||||||
|
NEXT_PUBLIC_KIOSK_API_KEY=la-api-key-que-generaste-en-el-paso-anterior
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Acceder al Kiosko
|
||||||
|
|
||||||
|
```
|
||||||
|
https://tu-dominio.com/kiosk/{location-id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
```
|
||||||
|
https://salonos.example.com/kiosk/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura de Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
salonOS/
|
||||||
|
├── supabase/
|
||||||
|
│ └── migrations/
|
||||||
|
│ └── 20260116000000_add_kiosk_system.sql
|
||||||
|
├── lib/
|
||||||
|
│ └── db/
|
||||||
|
│ └── types.ts
|
||||||
|
├── app/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── kiosk/
|
||||||
|
│ │ ├── authenticate/
|
||||||
|
│ │ │ └── route.ts
|
||||||
|
│ │ ├── bookings/
|
||||||
|
│ │ │ ├── route.ts
|
||||||
|
│ │ │ └── [shortId]/
|
||||||
|
│ │ │ └── confirm/
|
||||||
|
│ │ │ └── route.ts
|
||||||
|
│ │ ├── resources/
|
||||||
|
│ │ │ └── available/
|
||||||
|
│ │ │ └── route.ts
|
||||||
|
│ │ └── walkin/
|
||||||
|
│ │ └── route.ts
|
||||||
|
│ └── kiosk/
|
||||||
|
│ └── [locationId]/
|
||||||
|
│ └── page.tsx
|
||||||
|
├── components/
|
||||||
|
│ └── kiosk/
|
||||||
|
│ ├── BookingConfirmation.tsx
|
||||||
|
│ ├── WalkInFlow.tsx
|
||||||
|
│ └── ResourceAssignment.tsx
|
||||||
|
└── docs/
|
||||||
|
└── KIOSK_SYSTEM.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### 1. Test de Autenticación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://tu-dominio.com/api/kiosk/authenticate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"api_key": "tu-api-key"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test de Confirmación de Cita
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Buscar booking
|
||||||
|
curl "https://tu-dominio.com/api/kiosk/bookings?short_id=ABC123" \
|
||||||
|
-H "x-kiosk-api-key: tu-api-key"
|
||||||
|
|
||||||
|
# Confirmar booking
|
||||||
|
curl -X POST https://tu-dominio.com/api/kiosk/bookings/ABC123/confirm \
|
||||||
|
-H "x-kiosk-api-key: tu-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test de Walk-in
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://tu-dominio.com/api/kiosk/walkin \
|
||||||
|
-H "x-kiosk-api-key: tu-api-key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"customer_email": "cliente@email.com",
|
||||||
|
"customer_name": "Cliente Prueba",
|
||||||
|
"customer_phone": "8112345678",
|
||||||
|
"service_id": "service-uuid"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Características Clave
|
||||||
|
|
||||||
|
### Prioridad de Asignación de Recursos
|
||||||
|
|
||||||
|
El sistema asigna recursos automáticamente con este orden:
|
||||||
|
|
||||||
|
1. **Estaciones (stations)** - Prioridad alta
|
||||||
|
2. **Salas (rooms)** - Prioridad media
|
||||||
|
3. **Equipo (equipment)** - Prioridad baja
|
||||||
|
|
||||||
|
### Seguridad
|
||||||
|
|
||||||
|
- ✅ API key de 64 caracteres aleatorios
|
||||||
|
- ✅ Restricción opcional por IP
|
||||||
|
- ✅ Políticas RLS granulares
|
||||||
|
- ✅ Sin acceso a PII de clientes
|
||||||
|
- ✅ Audit logging completo
|
||||||
|
|
||||||
|
### UX para Cliente
|
||||||
|
|
||||||
|
- ✅ Interfaz simple y táctil-friendly
|
||||||
|
- ✅ Confirmación visual de acciones
|
||||||
|
- ✅ Validaciones en tiempo real
|
||||||
|
- ✅ Códigos de 6 caracteres fáciles de recordar
|
||||||
|
- ✅ Soporte para walk-ins inmediatos
|
||||||
|
|
||||||
|
## Próximos Pasos (Opcionales)
|
||||||
|
|
||||||
|
1. **Personalización del Diseño**
|
||||||
|
- Ajustar colores según branding del salón
|
||||||
|
- Agregar logo del salón
|
||||||
|
- Modificar textos según preferencia
|
||||||
|
|
||||||
|
2. **Integraciones**
|
||||||
|
- Google Calendar para sincronización
|
||||||
|
- Notificaciones por SMS/email al confirmar
|
||||||
|
- Pagos en el kiosko
|
||||||
|
|
||||||
|
3. **Funcionalidades Adicionales**
|
||||||
|
- Soporte multi-idioma
|
||||||
|
- Modo mantenimiento
|
||||||
|
- Reportes de uso
|
||||||
|
- Soporte para QR codes
|
||||||
|
|
||||||
|
## Soporte
|
||||||
|
|
||||||
|
Para más detalles, consulta `docs/KIOSK_SYSTEM.md` o revisa el código en los archivos mencionados.
|
||||||
244
docs/KIOSK_SYSTEM.md
Normal file
244
docs/KIOSK_SYSTEM.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# Sistema de Kiosko - SalonOS
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
El sistema de kiosko permite a los clientes interactuar con el salón mediante pantallas táctiles en la entrada, facilitando la confirmación de citas y la creación de reservas walk-in sin necesidad de personal.
|
||||||
|
|
||||||
|
## Características
|
||||||
|
|
||||||
|
### 1. Confirmación de Citas
|
||||||
|
- Los clientes pueden confirmar su llegada ingresando el código de 6 caracteres (short_id) de su cita
|
||||||
|
- Verificación de estado de la cita (pending → confirmed)
|
||||||
|
- Visualización de detalles limitados (sin PII)
|
||||||
|
|
||||||
|
### 2. Reservas Walk-in
|
||||||
|
- Creación de reservas inmediatas para clientes sin cita previa
|
||||||
|
- Asignación automática de recursos con prioridad
|
||||||
|
- Selección de servicios disponibles
|
||||||
|
- Registro de datos básicos del cliente
|
||||||
|
|
||||||
|
### 3. Asignación de Recursos con Prioridad
|
||||||
|
- **Prioridad 1 (Alta):** Estaciones (stations)
|
||||||
|
- **Prioridad 2 (Media):** Salas (rooms)
|
||||||
|
- **Prioridad 3 (Baja):** Equipo (equipment)
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
### Base de Datos
|
||||||
|
|
||||||
|
#### Nueva Tabla: `kiosks`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE kiosks (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
location_id UUID REFERENCES locations(id),
|
||||||
|
device_name VARCHAR(100) UNIQUE,
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
API key VARCHAR(64) UNIQUE,
|
||||||
|
ip_address INET,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Nuevo Rol: `'kiosk'`
|
||||||
|
Agregado al enum `user_role` para manejar permisos específicos.
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
|
||||||
|
#### Autenticación
|
||||||
|
- `POST /api/kiosk/authenticate` - Valida API key y devuelve información del kiosko
|
||||||
|
|
||||||
|
#### Bookings
|
||||||
|
- `GET /api/kiosk/bookings?short_id={id}` - Busca booking por short_id
|
||||||
|
- `GET /api/kiosk/bookings?date={date}` - Lista bookings de una fecha
|
||||||
|
- `POST /api/kiosk/bookings` - Crea nuevo booking
|
||||||
|
- `POST /api/kiosk/bookings/{shortId}/confirm` - Confirma booking (pending → confirmed)
|
||||||
|
|
||||||
|
#### Recursos
|
||||||
|
- `GET /api/kiosk/resources/available?start_time={...}&end_time={...}` - Lista recursos disponibles con prioridad
|
||||||
|
|
||||||
|
#### Walk-in
|
||||||
|
- `POST /api/kiosk/walkin` - Crea reserva walk-in inmediata
|
||||||
|
|
||||||
|
### Componentes UI
|
||||||
|
|
||||||
|
- `BookingConfirmation` - Flujo para confirmar citas existentes
|
||||||
|
- `WalkInFlow` - Flujo completo para crear reservas walk-in
|
||||||
|
- `ResourceAssignment` - Muestra recursos disponibles con prioridad
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
### Autenticación
|
||||||
|
- Basada en API key (64 caracteres aleatorios)
|
||||||
|
- Validación en cada request vía header `x-kiosk-api-key`
|
||||||
|
|
||||||
|
### Permisos RLS
|
||||||
|
|
||||||
|
#### El Kiosko PUEDE:
|
||||||
|
- Ver bookings de su location (solo: short_id, start_time, status)
|
||||||
|
- Crear bookings walk-in (clientes sin pre-reserva)
|
||||||
|
- Confirmar bookings existentes (solo cambiar status → confirmed)
|
||||||
|
- Ver/consultar resources disponibles de su location
|
||||||
|
- Ver services activos
|
||||||
|
- Ver datos básicos de su location
|
||||||
|
|
||||||
|
#### El Kiosko NO PUEDE:
|
||||||
|
- Ver datos sensibles del cliente (PII)
|
||||||
|
- Ver bookings de otras locations
|
||||||
|
- Modificar bookings existentes (solo status → confirmed)
|
||||||
|
- Cancelar bookings
|
||||||
|
- Ver datos de otros kioskos
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
### 1. Ejecutar Migración SQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# En Supabase SQL Editor o via CLI
|
||||||
|
psql -h <your-host> -U <your-user> -d <your-db> -f supabase/migrations/20260116000000_add_kiosk_system.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configurar Variables de Entorno
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_KIOSK_API_KEY=your-kiosk-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Crear Kioskos
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT create_kiosk(
|
||||||
|
'<location-id>',
|
||||||
|
'kiosk-entrance-1',
|
||||||
|
'Kiosko Entrada Principal',
|
||||||
|
'192.168.1.100'::INET
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANTE:** Guarda la API key generada de forma segura. Solo se muestra una vez.
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
### Iniciar Kiosko
|
||||||
|
|
||||||
|
1. Navega a `/kiosk/{locationId}` en tu navegador
|
||||||
|
2. El kiosko se autentica automáticamente usando la API key configurada
|
||||||
|
3. Verás la pantalla principal con dos opciones:
|
||||||
|
- **Confirmar Cita** - Para clientes con cita previa
|
||||||
|
- **Reserva Inmediata** - Para clientes sin cita (walk-in)
|
||||||
|
|
||||||
|
### Flujo de Confirmación de Cita
|
||||||
|
|
||||||
|
1. El cliente ingresa el código de 6 caracteres de su cita
|
||||||
|
2. El sistema busca el booking
|
||||||
|
3. Si existe y está en estado `pending`, muestra los detalles
|
||||||
|
4. El cliente confirma su llegada
|
||||||
|
5. El status cambia a `confirmed`
|
||||||
|
6. Se muestra confirmación visual
|
||||||
|
|
||||||
|
### Flujo de Walk-in
|
||||||
|
|
||||||
|
1. Seleccionar servicio disponible
|
||||||
|
2. Ingresar datos del cliente (nombre, email, teléfono opcional)
|
||||||
|
3. Verificar disponibilidad de recursos
|
||||||
|
4. Confirmar reserva
|
||||||
|
5. El sistema asigna automáticamente:
|
||||||
|
- Artista disponible (prioridad: staff → manager → artist)
|
||||||
|
- Recurso disponible con mayor prioridad
|
||||||
|
6. Se muestra código de reserva (short_id)
|
||||||
|
|
||||||
|
## Prioridad de Asignación de Recursos
|
||||||
|
|
||||||
|
La función `get_available_resources_with_priority()` ordena recursos por:
|
||||||
|
|
||||||
|
1. **Tipo** (station > room > equipment)
|
||||||
|
2. **Nombre** (alfabético)
|
||||||
|
|
||||||
|
Esto asegura que los kioskos siempre asignen la mejor opción disponible.
|
||||||
|
|
||||||
|
## API Key Management
|
||||||
|
|
||||||
|
### Generar Nueva API Key
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Para un kiosko existente (no soportado directamente)
|
||||||
|
-- Debes crear un nuevo kiosko y borrar el antiguo
|
||||||
|
|
||||||
|
DELETE FROM kiosks WHERE id = '<old-kiosk-id>';
|
||||||
|
|
||||||
|
SELECT create_kiosk(
|
||||||
|
'<location-id>',
|
||||||
|
'kiosk-new',
|
||||||
|
'Nuevo Kiosko',
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validar API Key en Frontend
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const response = await fetch('/api/kiosk/authenticate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
api_key: process.env.NEXT_PUBLIC_KIOSK_API_KEY
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoreo y Auditoría
|
||||||
|
|
||||||
|
### Audit Logs
|
||||||
|
|
||||||
|
Todas las acciones del kiosko se registran en `audit_logs` con:
|
||||||
|
- `performed_by_role = 'kiosk'`
|
||||||
|
- `entity_type = 'bookings'` u otros
|
||||||
|
- `action = 'create' | 'update' | 'status_change'`
|
||||||
|
|
||||||
|
### Consultas Útiles
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Ver kioskos activos
|
||||||
|
SELECT device_name, display_name, ip_address, location_id
|
||||||
|
FROM kiosks
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
-- Ver bookings creados por kiosko hoy
|
||||||
|
SELECT * FROM bookings
|
||||||
|
WHERE DATE(created_at) = CURRENT_DATE
|
||||||
|
AND metadata @> '{"source": "kiosk"}';
|
||||||
|
|
||||||
|
-- Ver confirmaciones de kiosko
|
||||||
|
SELECT * FROM audit_logs
|
||||||
|
WHERE performed_by_role = 'kiosk'
|
||||||
|
AND action = 'status_change'
|
||||||
|
AND created_at >= NOW() - INTERVAL '1 hour';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: "Invalid API key"
|
||||||
|
- Verifica que la API key esté configurada en `.env`
|
||||||
|
- Verifica que el kiosko exista y esté activo en la base de datos
|
||||||
|
- Revisa los logs del servidor
|
||||||
|
|
||||||
|
### Error: "No resources available"
|
||||||
|
- Verifica que hay recursos activos en la location
|
||||||
|
- Revisa los horarios de los bookings existentes
|
||||||
|
- Asegúrate de que el service_id es válido
|
||||||
|
|
||||||
|
### Error: "Booking not found in kiosk location"
|
||||||
|
- Verifica que el short_id es correcto
|
||||||
|
- Confirma que el booking pertenece a la location del kiosko
|
||||||
|
|
||||||
|
## Mejoras Futuras
|
||||||
|
|
||||||
|
- [ ] Soporte para múltiples idiomas
|
||||||
|
- [ ] Integración con pagos en el kiosko
|
||||||
|
- [ ] Sistema de notificaciones al confirmar cita
|
||||||
|
- [ ] Modo mantenimiento para kioskos individuales
|
||||||
|
- [ ] Reportes de uso de kioskos
|
||||||
|
- [ ] Soporte para escanear QR codes en lugar de ingresar short_id
|
||||||
|
- [ ] Sincronización offline
|
||||||
|
- [ ] Soporte para devices móviles (tablets)
|
||||||
341
docs/PROJECT_UPDATE_JAN_2026.md
Normal file
341
docs/PROJECT_UPDATE_JAN_2026.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# Actualización del Proyecto - Enero 2026
|
||||||
|
|
||||||
|
## Resumen Ejecutivo
|
||||||
|
|
||||||
|
Se ha completado el **Sistema de Kiosko** y la **Actualización de Recursos** del sistema SalonOS. Estos cambios representan una expansión significativa de las capacidades del sistema, mejorando la operativa del salón y la experiencia del cliente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cambios Implementados
|
||||||
|
|
||||||
|
### 1. Sistema de Kiosko ✅
|
||||||
|
|
||||||
|
#### Nuevo Rol: `kiosk`
|
||||||
|
- Agregado al enum `user_role`
|
||||||
|
- Permisos específicos: confirmación de citas y walk-ins
|
||||||
|
- Sin acceso a PII de clientes
|
||||||
|
|
||||||
|
#### Nueva Tabla: `kiosks`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE kiosks (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
location_id UUID NOT NULL REFERENCES locations(id),
|
||||||
|
device_name VARCHAR(100) UNIQUE,
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
api_key VARCHAR(64) UNIQUE,
|
||||||
|
ip_address INET,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Funciones SQL Implementadas
|
||||||
|
- `generate_kiosk_api_key()` - Genera API keys de 64 caracteres
|
||||||
|
- `get_current_kiosk_id()` - Obtiene kiosk actual por API key
|
||||||
|
- `is_kiosk()` - Verifica si el usuario actual es un kiosko
|
||||||
|
- `get_current_kiosk_location_id()` - Obtiene location del kiosko
|
||||||
|
- `get_available_resources_with_priority()` - Asigna recursos con prioridad
|
||||||
|
|
||||||
|
#### API Routes Implementadas
|
||||||
|
- `POST /api/kiosk/authenticate` - Autenticación de kiosko
|
||||||
|
- `GET /api/kiosk/bookings` - Lista bookings de la location
|
||||||
|
- `POST /api/kiosk/bookings` - Crea nuevo booking
|
||||||
|
- `POST /api/kiosk/bookings/[shortId]/confirm` - Confirma booking
|
||||||
|
- `GET /api/kiosk/resources/available` - Ver recursos disponibles
|
||||||
|
- `POST /api/kiosk/walkin` - Crea reserva walk-in
|
||||||
|
|
||||||
|
#### Componentes UI
|
||||||
|
- `BookingConfirmation.tsx` - Flujo para confirmar citas existentes
|
||||||
|
- `WalkInFlow.tsx` - Flujo completo para crear reservas walk-in
|
||||||
|
- `ResourceAssignment.tsx` - Muestra recursos con prioridad
|
||||||
|
- `kiosk/[locationId]/page.tsx` - Pantalla principal del kiosko
|
||||||
|
|
||||||
|
#### Características Clave
|
||||||
|
- **Autenticación por API key** - 64 caracteres aleatorios
|
||||||
|
- **Asignación automática de recursos** - Prioridad: mkup > lshs > pedi > mani
|
||||||
|
- **Sin acceso a PII** - Los kioskos no ven datos sensibles de clientes
|
||||||
|
- **Audit logging completo** - Todas las acciones registradas
|
||||||
|
- **UI táctil-friendly** - Diseñado para pantallas táctiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Actualización de Recursos ✅
|
||||||
|
|
||||||
|
#### Cambio de Nombres
|
||||||
|
Los recursos ahora usan códigos alfanuméricos estandarizados en lugar de nombres descriptivos.
|
||||||
|
|
||||||
|
#### Estructura por Location
|
||||||
|
Cada location tiene:
|
||||||
|
- **3 Estaciones de Maquillaje** (mkup-01, mkup-02, mkup-03)
|
||||||
|
- **1 Cama de Pestañas** (lshs-01)
|
||||||
|
- **4 Estaciones de Pedicure** (pedi-01, pedi-02, pedi-03, pedi-04)
|
||||||
|
- **4 Estaciones de Manicure** (mani-01, mani-02, mani-03, mani-04)
|
||||||
|
|
||||||
|
#### Total por Location: 12 Recursos
|
||||||
|
|
||||||
|
#### Impacto
|
||||||
|
- ⚠️ **Todos los bookings anteriores han sido eliminados** por CASCADE DELETE
|
||||||
|
- La migración crea automáticamente recursos para nuevas locations
|
||||||
|
- Los códigos son consistentes y fáciles de memorizar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Actualización de Types ✅
|
||||||
|
|
||||||
|
#### Nuevos Tipos
|
||||||
|
```typescript
|
||||||
|
export type UserRole = 'admin' | 'manager' | 'staff' | 'artist' | 'customer' | 'kiosk'
|
||||||
|
export type CustomerTier = 'free' | 'gold' | 'black' | 'VIP'
|
||||||
|
|
||||||
|
export interface Kiosk {
|
||||||
|
id: string
|
||||||
|
location_id: string
|
||||||
|
device_name: string
|
||||||
|
display_name: string
|
||||||
|
api_key: string
|
||||||
|
ip_address?: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Políticas RLS Actualizadas ✅
|
||||||
|
|
||||||
|
#### Nuevas Políticas para Kiosk
|
||||||
|
- **Bookings**: Puede ver y crear bookings de su location (sin PII)
|
||||||
|
- **Resources**: Puede ver recursos disponibles de su location
|
||||||
|
- **Locations**: Solo puede ver su propia location
|
||||||
|
- **Customers**: **NO** puede ver datos de clientes (PII restriction)
|
||||||
|
- **Services**: Puede ver servicios activos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Documentación Actualizada ✅
|
||||||
|
|
||||||
|
#### Nuevos Documentos
|
||||||
|
- `docs/KIOSK_SYSTEM.md` - Documentación completa del sistema de kiosko
|
||||||
|
- `docs/KIOSK_IMPLEMENTATION.md` - Guía rápida de implementación
|
||||||
|
- `docs/RESOURCES_UPDATE.md` - Documentación de actualización de recursos
|
||||||
|
|
||||||
|
#### Documentos Actualizados
|
||||||
|
- `TASKS.md` - Estado actual del proyecto y próximos pasos
|
||||||
|
- `README.md` - Descripción del sistema incluyendo kiosko
|
||||||
|
- `PRD.md` - Referencia de reglas de negocio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estado Actual del Proyecto
|
||||||
|
|
||||||
|
### Completado ✅ (90% Fase 1)
|
||||||
|
|
||||||
|
#### Infraestructura Base
|
||||||
|
- ✅ Proyecto Supabase configurado
|
||||||
|
- ✅ Roles definidos (Admin, Manager, Staff, Artist, Customer, Kiosk)
|
||||||
|
- ✅ Políticas RLS implementadas
|
||||||
|
|
||||||
|
#### Esquema de Base de Datos
|
||||||
|
- ✅ Todas las tablas creadas y relacionadas
|
||||||
|
- ✅ Constraints y foreign keys
|
||||||
|
- ✅ Campos de auditoría (created_at, updated_at)
|
||||||
|
- ✅ Recursos actualizados con códigos estandarizados
|
||||||
|
|
||||||
|
#### Generadores y Automatismos
|
||||||
|
- ✅ Short ID generator (6 chars, collision-safe)
|
||||||
|
- ✅ Invitation code generator (10 chars)
|
||||||
|
- ✅ Reseteo semanal de invitaciones
|
||||||
|
- ✅ Audit logging completo
|
||||||
|
|
||||||
|
#### Sistema de Kiosko
|
||||||
|
- ✅ Tabla kiosks con API key authentication
|
||||||
|
- ✅ API routes completas
|
||||||
|
- ✅ Componentes UI funcionales
|
||||||
|
- ✅ Asignación inteligente de recursos
|
||||||
|
|
||||||
|
### Pendiente ⏳ (10% Fase 1)
|
||||||
|
|
||||||
|
- ⏳ Testing exhaustivo del sistema de kiosko
|
||||||
|
- ⏳ Validación de migración de recursos en producción
|
||||||
|
- ⏳ Implementación de Auth con Supabase Magic Links/SMS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Próximos Pasos Inmediatos
|
||||||
|
|
||||||
|
### Prioridad Alta - Esta Semana
|
||||||
|
|
||||||
|
1. **Testing del Sistema de Kiosko**
|
||||||
|
- Test de autenticación de API key
|
||||||
|
- Test de confirmación de citas
|
||||||
|
- Test de walk-ins
|
||||||
|
- Verificar asignación de recursos con prioridad
|
||||||
|
|
||||||
|
2. **Ejecutar Migración de Recursos**
|
||||||
|
- Aplicar migración en Supabase
|
||||||
|
- Verificar creación correcta de recursos
|
||||||
|
- Confirmar no hay bookings huérfanos
|
||||||
|
|
||||||
|
3. **Configurar Kioskos en Producción**
|
||||||
|
- Crear kioskos para cada location
|
||||||
|
- Configurar API keys
|
||||||
|
- Probar acceso desde pantalla táctil
|
||||||
|
|
||||||
|
### Prioridad Media - Próximas 2 Semanas
|
||||||
|
|
||||||
|
4. **Implementar API Routes para Bookings (Cliente)**
|
||||||
|
- Listar bookings del cliente
|
||||||
|
- Crear nuevo booking
|
||||||
|
- Modificar/cancelar booking
|
||||||
|
|
||||||
|
5. **Implementar Lógica de Disponibilidad**
|
||||||
|
- Función para buscar disponibilidad de staff
|
||||||
|
- Función para buscar disponibilidad de recursos
|
||||||
|
|
||||||
|
6. **Implementar Notificaciones Básicas**
|
||||||
|
- Email de confirmación
|
||||||
|
- Email de recordatorio (24h antes)
|
||||||
|
|
||||||
|
### Prioridad Baja - Próximo Mes
|
||||||
|
|
||||||
|
7. **Desarrollar HQ Dashboard (Fase 4)**
|
||||||
|
- Calendario multi-columna
|
||||||
|
- Gestión operativa de recursos y staff
|
||||||
|
|
||||||
|
8. **Integración con Stripe (Fase 3)**
|
||||||
|
- Configurar Stripe
|
||||||
|
- Implementar webhooks
|
||||||
|
- Lógica de depósitos dinámicos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Impacto en el Sistema
|
||||||
|
|
||||||
|
### Operativo
|
||||||
|
- **Reducción de carga de staff**: Los clientes pueden confirmar citas y crear walk-ins sin asistencia
|
||||||
|
- **Mejor gestión de recursos**: Asignación automática con prioridad
|
||||||
|
- **Mayor eficiencia**: Menos tiempo perdido en confirmación manual
|
||||||
|
|
||||||
|
### Técnico
|
||||||
|
- **Arquitectura extensible**: Sistema preparado para agregar más kioskos
|
||||||
|
- **Seguridad robusta**: API keys de 64 caracteres, RLS granulares
|
||||||
|
- **Auditoría completa**: Toda acción de kiosko registrada
|
||||||
|
|
||||||
|
### Experiencia del Cliente
|
||||||
|
- **Autoservicio**: Confirmación de cita en segundos
|
||||||
|
- **Walk-ins más rápidos**: Creación de reserva sin espera
|
||||||
|
- **Claridad**: Códigos de recursos consistentes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos Creados/Modificados
|
||||||
|
|
||||||
|
### Nuevos Archivos (12)
|
||||||
|
|
||||||
|
```
|
||||||
|
supabase/migrations/
|
||||||
|
├── 20260116000000_add_kiosk_system.sql
|
||||||
|
└── 20260116010000_update_resources.sql
|
||||||
|
|
||||||
|
app/api/kiosk/
|
||||||
|
├── authenticate/route.ts
|
||||||
|
├── bookings/route.ts
|
||||||
|
├── bookings/[shortId]/confirm/route.ts
|
||||||
|
├── resources/available/route.ts
|
||||||
|
└── walkin/route.ts
|
||||||
|
|
||||||
|
app/kiosk/
|
||||||
|
└── [locationId]/page.tsx
|
||||||
|
|
||||||
|
components/kiosk/
|
||||||
|
├── BookingConfirmation.tsx
|
||||||
|
├── WalkInFlow.tsx
|
||||||
|
└── ResourceAssignment.tsx
|
||||||
|
|
||||||
|
components/ui/
|
||||||
|
├── button.tsx
|
||||||
|
└── input.tsx
|
||||||
|
|
||||||
|
docs/
|
||||||
|
├── KIOSK_SYSTEM.md
|
||||||
|
├── KIOSK_IMPLEMENTATION.md
|
||||||
|
└── RESOURCES_UPDATE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Archivos Modificados (4)
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/db/types.ts
|
||||||
|
TASKS.md
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Métricas
|
||||||
|
|
||||||
|
### Líneas de Código Agregadas
|
||||||
|
- SQL: ~800 líneas
|
||||||
|
- TypeScript: ~900 líneas
|
||||||
|
- Documentación: ~1200 líneas
|
||||||
|
|
||||||
|
### Total: ~2,900 líneas de código y documentación
|
||||||
|
|
||||||
|
### Funciones SQL Agregadas: 7
|
||||||
|
### API Routes Agregadas: 6
|
||||||
|
### Componentes UI Agregadas: 4
|
||||||
|
### Migraciones SQL Agregadas: 2
|
||||||
|
### Documentos Agregados: 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisiones de Diseño
|
||||||
|
|
||||||
|
### ¿Por qué API Key Authentication para Kioskos?
|
||||||
|
- **Simplicidad**: No requiere login/password
|
||||||
|
- **Seguridad**: Claves de 64 caracteres, difíciles de adivinar
|
||||||
|
- **Gestión**: Fácil rotar claves
|
||||||
|
- **Restricción**: Posible limitar por IP address
|
||||||
|
|
||||||
|
### ¿Por Qué Códigos Alfanuméricos para Recursos?
|
||||||
|
- **Consistencia**: Mismo patrón en todas las locations
|
||||||
|
- **Brevedad**: Fáciles de comunicar (mkup-01 vs "Estación de Maquillaje 1")
|
||||||
|
- **Programabilidad**: Fáciles de parsear y validar
|
||||||
|
- **Escalabilidad**: Fácil agregar nuevos recursos
|
||||||
|
|
||||||
|
### ¿Por Qué Prioridad de Recursos?
|
||||||
|
- **Optimización**: Asignar mejor recurso disponible
|
||||||
|
- **Lógica Consistente**: Mismo criterio para todas las asignaciones
|
||||||
|
- **Eficiencia**: Uso óptimo de estaciones físicas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Riesgos y Mitigaciones
|
||||||
|
|
||||||
|
### Riesgo: Kiosko Sin Conexión
|
||||||
|
**Mitigación**: Considerar modo offline para futura implementación
|
||||||
|
|
||||||
|
### Riesgo: API Key Expuesta
|
||||||
|
**Mitigación**: Restricción opcional por IP, rotación periódica de claves
|
||||||
|
|
||||||
|
### Riesgo: Confusión por Cambio de Recursos
|
||||||
|
**Mitigación**: Documentación clara, comunicación previa a staff/clients
|
||||||
|
|
||||||
|
### Riesgo: Bookings Eliminados por CASCADE DELETE
|
||||||
|
**Mitigación**: En producción, implementar migración de datos antes de eliminar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusión
|
||||||
|
|
||||||
|
El sistema de kiosko y la actualización de recursos representan un avance significativo en las capacidades operativas de SalonOS. Estas mejoras preparan el sistema para una implementación en producción, optimizando tanto la experiencia del cliente como la eficiencia operativa del salón.
|
||||||
|
|
||||||
|
El proyecto está en un estado sólido (90% completado de Fase 1), con una infraestructura robusta y segura lista para el desarrollo de las fases restantes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fecha**: 16 de Enero, 2026
|
||||||
|
**Versión**: v1.0.0
|
||||||
|
**Fase**: Fase 1 - Cimientos y CRM (90% completado)
|
||||||
251
docs/RESOURCES_UPDATE.md
Normal file
251
docs/RESOURCES_UPDATE.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# Actualización de Recursos - SalonOS
|
||||||
|
|
||||||
|
## Cambios Realizados
|
||||||
|
|
||||||
|
### Recursos Anteriores (Eliminados)
|
||||||
|
Los recursos tenían nombres descriptivos como:
|
||||||
|
- "Sillón Pedicure 1", "Sillón Pedicure 2", "Sillón Pedicure 3"
|
||||||
|
- "Estación Manicure 1", "Estación Manicure 2", "Estación Manicure 3", "Estación Manicure 4"
|
||||||
|
- "Estación Maquillaje"
|
||||||
|
- "Cama Pestañas"
|
||||||
|
|
||||||
|
### Nuevos Recursos (Implementados)
|
||||||
|
Los recursos ahora usan códigos alfanuméricos estandarizados:
|
||||||
|
|
||||||
|
| Tipo | Código | Cantidad | Descripción |
|
||||||
|
|------|--------|----------|-------------|
|
||||||
|
| Maquillaje | `mkup` | 3 | Estaciones de maquillaje |
|
||||||
|
| Pestañas | `lshs` | 1 | Cama de pestañas |
|
||||||
|
| Pedicure | `pedi` | 4 | Estaciones de pedicure |
|
||||||
|
| Manicure | `mani` | 4 | Estaciones de manicure |
|
||||||
|
|
||||||
|
### Formato de Nombres
|
||||||
|
Los recursos siguen el patrón: `{código}-{número}`
|
||||||
|
|
||||||
|
Ejemplos:
|
||||||
|
- `mkup-01`, `mkup-02`, `mkup-03`
|
||||||
|
- `lshs-01`
|
||||||
|
- `pedi-01`, `pedi-02`, `pedi-03`, `pedi-04`
|
||||||
|
- `mani-01`, `mani-02`, `mani-03`, `mani-04`
|
||||||
|
|
||||||
|
## Detalles por Tipo
|
||||||
|
|
||||||
|
### Maquillaje (`mkup`)
|
||||||
|
- **Total:** 3 estaciones por location
|
||||||
|
- **Tipo:** station
|
||||||
|
- **Capacidad:** 1 persona por estación
|
||||||
|
- **Uso:** Servicios de maquillaje profesional
|
||||||
|
|
||||||
|
### Pestañas (`lshs`)
|
||||||
|
- **Total:** 1 cama por location
|
||||||
|
- **Tipo:** station
|
||||||
|
- **Capacidad:** 1 persona
|
||||||
|
- **Uso:** Extensiones de pestañas
|
||||||
|
|
||||||
|
### Pedicure (`pedi`)
|
||||||
|
- **Total:** 4 estaciones por location
|
||||||
|
- **Tipo:** station
|
||||||
|
- **Capacidad:** 1 persona por estación
|
||||||
|
- **Uso:** Servicios de pedicure
|
||||||
|
|
||||||
|
### Manicure (`mani`)
|
||||||
|
- **Total:** 4 estaciones por location
|
||||||
|
- **Tipo:** station
|
||||||
|
- **Capacidad:** 1 persona por estación
|
||||||
|
- **Uso:** Servicios de manicure
|
||||||
|
|
||||||
|
## Impacto en el Sistema
|
||||||
|
|
||||||
|
### Bookings Eliminados
|
||||||
|
⚠️ **IMPORTANTE:** Debido a la restricción `CASCADE DELETE` en la tabla `resources`, todos los bookings que referenciaban los recursos anteriores han sido eliminados.
|
||||||
|
|
||||||
|
Esto significa que:
|
||||||
|
- No hay bookings activos en el sistema
|
||||||
|
- Los clientes deberán reprogramar sus citas
|
||||||
|
- Se debe informar a los usuarios del cambio
|
||||||
|
|
||||||
|
### Cómo Afecta al Kiosko
|
||||||
|
El kiosko ahora asignará recursos usando los nuevos códigos:
|
||||||
|
- Cuando un cliente solicita maquillaje → asigna `mkup-XX`
|
||||||
|
- Cuando un cliente solicita pestañas → asigna `lshs-01`
|
||||||
|
- Cuando un cliente solicita pedicure → asigna `pedi-XX`
|
||||||
|
- Cuando un cliente solicita manicure → asigna `mani-XX`
|
||||||
|
|
||||||
|
### Mapeo de Servicios a Recursos
|
||||||
|
|
||||||
|
Para mantener la consistencia, los servicios deberían asignarse a los recursos correctos:
|
||||||
|
|
||||||
|
| Servicio | Recurso Recomendado | Notas |
|
||||||
|
|----------|---------------------|-------|
|
||||||
|
| Maquillaje Profesional | `mkup-XX` | Cualquiera de las 3 estaciones |
|
||||||
|
| Extensión de Pestañas | `lshs-01` | Único recurso disponible |
|
||||||
|
| Pedicure Spa | `pedi-XX` | Cualquiera de las 4 estaciones |
|
||||||
|
| Manicure Gel | `mani-XX` | Cualquiera de las 4 estaciones |
|
||||||
|
| Uñas Acrílicas | `mani-XX` | Cualquiera de las 4 estaciones |
|
||||||
|
|
||||||
|
## Ejecutar la Migración
|
||||||
|
|
||||||
|
### Opción 1: Via Supabase Dashboard
|
||||||
|
|
||||||
|
1. Ve a: https://supabase.com/dashboard/project/pvvwbnybkadhreuqijsl/sql
|
||||||
|
2. Copia el contenido de `supabase/migrations/20260116010000_update_resources.sql`
|
||||||
|
3. Pega en el SQL Editor
|
||||||
|
4. Haz clic en "Run"
|
||||||
|
5. Verifica el output en la consola
|
||||||
|
|
||||||
|
### Opción 2: Via CLI (si está configurado)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
supabase db push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verificar la Migración
|
||||||
|
|
||||||
|
### Consulta SQL para Ver Recursos
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
l.name AS location,
|
||||||
|
r.name AS resource_code,
|
||||||
|
r.type AS resource_type,
|
||||||
|
r.capacity,
|
||||||
|
r.is_active
|
||||||
|
FROM resources r
|
||||||
|
JOIN locations l ON l.id = r.location_id
|
||||||
|
WHERE l.is_active = true
|
||||||
|
ORDER BY l.name, r.name;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resultado Esperado
|
||||||
|
|
||||||
|
```
|
||||||
|
location | resource_code | resource_type | capacity | is_active
|
||||||
|
------------------|---------------|---------------|----------|----------
|
||||||
|
ANCHOR:23 - Via KLAVA | lshs-01 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | mani-01 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | mani-02 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | mani-03 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | mani-04 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | mkup-01 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | mkup-02 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | mkup-03 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | pedi-01 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | pedi-02 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | pedi-03 | station | 1 | true
|
||||||
|
ANCHOR:23 - Via KLAVA | pedi-04 | station | 1 | true
|
||||||
|
TEST - Salón Principal | lshs-01 | station | 1 | true
|
||||||
|
TEST - Salón Principal | mani-01 | station | 1 | true
|
||||||
|
TEST - Salón Principal | mani-02 | station | 1 | true
|
||||||
|
TEST - Salón Principal | mani-03 | station | 1 | true
|
||||||
|
TEST - Salón Principal | mani-04 | station | 1 | true
|
||||||
|
TEST - Salón Principal | mkup-01 | station | 1 | true
|
||||||
|
TEST - Salón Principal | mkup-02 | station | 1 | true
|
||||||
|
TEST - Salón Principal | mkup-03 | station | 1 | true
|
||||||
|
TEST - Salón Principal | pedi-01 | station | 1 | true
|
||||||
|
TEST - Salón Principal | pedi-02 | station | 1 | true
|
||||||
|
TEST - Salón Principal | pedi-03 | station | 1 | true
|
||||||
|
TEST - Salón Principal | pedi-04 | station | 1 | true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Actualizar el Seed Data (Opcional)
|
||||||
|
|
||||||
|
Si deseas mantener el archivo de seed consistente, actualiza la sección de recursos en `20260115235900_seed_data.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- REEMPLAZAR ESTA SECCIÓN EN EL SEED
|
||||||
|
-- 2. Crear Resources (solo si no existen)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Para ANCHOR:23 - Via KLAVA
|
||||||
|
FOR i IN 1..3 LOOP
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM resources r
|
||||||
|
JOIN locations l ON l.id = r.location_id
|
||||||
|
WHERE l.name = 'ANCHOR:23 - Via KLAVA' AND r.name = 'mkup-' || LPAD(i::TEXT, 2, '0')
|
||||||
|
) THEN
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
SELECT id, 'mkup-' || LPAD(i::TEXT, 2, '0'), 'station', 1, true
|
||||||
|
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
SELECT id, 'lshs-01', 'station', 1, true
|
||||||
|
FROM locations l
|
||||||
|
WHERE l.name = 'ANCHOR:23 - Via KLAVA'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM resources r
|
||||||
|
WHERE r.location_id = l.id AND r.name = 'lshs-01'
|
||||||
|
);
|
||||||
|
|
||||||
|
FOR i IN 1..4 LOOP
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM resources r
|
||||||
|
JOIN locations l ON l.id = r.location_id
|
||||||
|
WHERE l.name = 'ANCHOR:23 - Via KLAVA' AND r.name = 'pedi-' || LPAD(i::TEXT, 2, '0')
|
||||||
|
) THEN
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
SELECT id, 'pedi-' || LPAD(i::TEXT, 2, '0'), 'station', 1, true
|
||||||
|
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
FOR i IN 1..4 LOOP
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM resources r
|
||||||
|
JOIN locations l ON l.id = r.location_id
|
||||||
|
WHERE l.name = 'ANCHOR:23 - Via KLAVA' AND r.name = 'mani-' || LPAD(i::TEXT, 2, '0')
|
||||||
|
) THEN
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
SELECT id, 'mani-' || LPAD(i::TEXT, 2, '0'), 'station', 1, true
|
||||||
|
FROM locations WHERE name = 'ANCHOR:23 - Via KLAVA';
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Repetir mismo patrón para otras locations...
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas Importantes
|
||||||
|
|
||||||
|
### IDs de Resources
|
||||||
|
Cada location tendrá sus propios recursos con IDs únicos. Por ejemplo:
|
||||||
|
- ANCHOR:23 - Via KLAVA → `mkup-01` tiene un ID específico
|
||||||
|
- TEST - Salón Principal → `mkup-01` tiene un ID diferente
|
||||||
|
|
||||||
|
### Agregar Nuevas Locations
|
||||||
|
Cuando agregues una nueva location, la migración de actualización de recursos (`20260116010000_update_resources.sql`) creará automáticamente los 12 recursos para ella:
|
||||||
|
- 3 mkup
|
||||||
|
- 1 lshs
|
||||||
|
- 4 pedi
|
||||||
|
- 4 mani
|
||||||
|
|
||||||
|
### Modificar Cantidades
|
||||||
|
Si necesitas cambiar las cantidades en el futuro, modifica la migración:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Ejemplo: cambiar a 5 estaciones de maquillaje
|
||||||
|
FOR i IN 1..5 LOOP
|
||||||
|
INSERT INTO resources ...
|
||||||
|
END LOOP;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Soporte y Troubleshooting
|
||||||
|
|
||||||
|
### Error: "Resources table is empty"
|
||||||
|
- Ejecuta la migración de actualización de recursos
|
||||||
|
- Verifica que las locations estén activas
|
||||||
|
|
||||||
|
### Error: "Booking references non-existent resource"
|
||||||
|
- Esto es normal después de la migración
|
||||||
|
- Los bookings anteriores fueron eliminados por CASCADE DELETE
|
||||||
|
- Crea nuevos bookings con el sistema actualizado
|
||||||
|
|
||||||
|
### Consulta para Ver Locations sin Recursos
|
||||||
|
```sql
|
||||||
|
SELECT l.id, l.name
|
||||||
|
FROM locations l
|
||||||
|
LEFT JOIN resources r ON r.location_id = l.id
|
||||||
|
WHERE l.is_active = true
|
||||||
|
AND r.id IS NULL;
|
||||||
|
```
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Types based on SalonOS database schema
|
// Types based on SalonOS database schema
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'manager' | 'staff' | 'artist' | 'customer'
|
export type UserRole = 'admin' | 'manager' | 'staff' | 'artist' | 'customer' | 'kiosk'
|
||||||
export type CustomerTier = 'free' | 'gold'
|
export type CustomerTier = 'free' | 'gold' | 'black' | 'VIP'
|
||||||
export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'completed' | 'no_show'
|
export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'completed' | 'no_show'
|
||||||
export type InvitationStatus = 'pending' | 'used' | 'expired'
|
export type InvitationStatus = 'pending' | 'used' | 'expired'
|
||||||
export type ResourceType = 'station' | 'room' | 'equipment'
|
export type ResourceType = 'station' | 'room' | 'equipment'
|
||||||
@@ -43,6 +43,19 @@ export interface Staff {
|
|||||||
location?: Location
|
location?: Location
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Kiosk {
|
||||||
|
id: string
|
||||||
|
location_id: string
|
||||||
|
device_name: string
|
||||||
|
display_name: string
|
||||||
|
api_key: string
|
||||||
|
ip_address?: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
location?: Location
|
||||||
|
}
|
||||||
|
|
||||||
export interface Service {
|
export interface Service {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -148,6 +161,11 @@ export type Database = {
|
|||||||
Insert: Omit<Staff, 'id' | 'created_at' | 'updated_at'>
|
Insert: Omit<Staff, 'id' | 'created_at' | 'updated_at'>
|
||||||
Update: Partial<Omit<Staff, 'id' | 'created_at'>>
|
Update: Partial<Omit<Staff, 'id' | 'created_at'>>
|
||||||
}
|
}
|
||||||
|
kiosks: {
|
||||||
|
Row: Kiosk
|
||||||
|
Insert: Omit<Kiosk, 'id' | 'created_at' | 'updated_at' | 'api_key'>
|
||||||
|
Update: Partial<Omit<Kiosk, 'id' | 'created_at' | 'updated_at' | 'api_key'>>
|
||||||
|
}
|
||||||
services: {
|
services: {
|
||||||
Row: Service
|
Row: Service
|
||||||
Insert: Omit<Service, 'id' | 'created_at' | 'updated_at'>
|
Insert: Omit<Service, 'id' | 'created_at' | 'updated_at'>
|
||||||
|
|||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
327
supabase/migrations/20260116000000_add_kiosk_system.sql
Normal file
327
supabase/migrations/20260116000000_add_kiosk_system.sql
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- SALONOS - KIOSK IMPLEMENTATION
|
||||||
|
-- Agregar rol 'kiosk' y tabla kiosks al sistema
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- AGREGAR ROL 'kiosk' AL ENUM user_role
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'kiosk' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'user_role')) THEN
|
||||||
|
ALTER TYPE user_role ADD VALUE 'kiosk' BEFORE 'customer';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CREAR TABLA KIOSKS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kiosks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
||||||
|
device_name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
display_name VARCHAR(100) NOT NULL,
|
||||||
|
api_key VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
ip_address INET,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CREAR ÍNDICES PARA KIOSKS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kiosks_location ON kiosks(location_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kiosks_api_key ON kiosks(api_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kiosks_active ON kiosks(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kiosks_ip ON kiosks(ip_address);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CREAR TRIGGER UPDATE_AT PARA KIOSKS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS kiosks_updated_at ON kiosks;
|
||||||
|
CREATE TRIGGER kiosks_updated_at BEFORE UPDATE ON kiosks
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN PARA GENERAR API KEY
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION generate_kiosk_api_key()
|
||||||
|
RETURNS VARCHAR(64) AS $$
|
||||||
|
DECLARE
|
||||||
|
chars VARCHAR(62) := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
api_key VARCHAR(64);
|
||||||
|
attempts INT := 0;
|
||||||
|
max_attempts INT := 10;
|
||||||
|
BEGIN
|
||||||
|
LOOP
|
||||||
|
api_key := '';
|
||||||
|
FOR i IN 1..64 LOOP
|
||||||
|
api_key := api_key || substr(chars, floor(random() * 62 + 1)::INT, 1);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM kiosks WHERE api_key = api_key) THEN
|
||||||
|
RETURN api_key;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
attempts := attempts + 1;
|
||||||
|
IF attempts >= max_attempts THEN
|
||||||
|
RAISE EXCEPTION 'Failed to generate unique api_key after % attempts', max_attempts;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN PARA OBTENER KIOSK ACTUAL
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_current_kiosk_id()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
current_kiosk_id UUID;
|
||||||
|
api_key_param TEXT;
|
||||||
|
BEGIN
|
||||||
|
api_key_param := current_setting('app.kiosk_api_key', true);
|
||||||
|
|
||||||
|
IF api_key_param IS NOT NULL THEN
|
||||||
|
SELECT id INTO current_kiosk_id
|
||||||
|
FROM kiosks
|
||||||
|
WHERE api_key = api_key_param AND is_active = true
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN current_kiosk_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN HELPER is_kiosk()
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION is_kiosk()
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN get_current_kiosk_id() IS NOT NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN PARA OBTENER LOCATION DEL KIOSK ACTUAL
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_current_kiosk_location_id()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
location_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT location_id INTO location_id
|
||||||
|
FROM kiosks
|
||||||
|
WHERE id = get_current_kiosk_id();
|
||||||
|
|
||||||
|
RETURN location_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ENABLE RLS ON KIOSKS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
ALTER TABLE kiosks ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- POLICY PARA KIOSKS: Solo admin/manager pueden ver/modificar
|
||||||
|
DROP POLICY IF EXISTS "kiosks_select_admin_manager" ON kiosks;
|
||||||
|
CREATE POLICY "kiosks_select_admin_manager" ON kiosks
|
||||||
|
FOR SELECT
|
||||||
|
USING (get_current_user_role() IN ('admin', 'manager'));
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "kiosks_modify_admin_manager" ON kiosks;
|
||||||
|
CREATE POLICY "kiosks_modify_admin_manager" ON kiosks
|
||||||
|
FOR ALL
|
||||||
|
USING (get_current_user_role() IN ('admin', 'manager'));
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- AUDIT LOG TRIGGER PARA KIOSKS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS audit_kiosks ON kiosks;
|
||||||
|
CREATE TRIGGER audit_kiosks AFTER INSERT OR UPDATE OR DELETE ON kiosks
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION log_audit();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- POLÍTICAS RLS PARA KIOSK EN OTRAS TABLAS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- BOOKINGS: Kiosk puede ver bookings de su location (limitado)
|
||||||
|
DROP POLICY IF EXISTS "bookings_select_kiosk" ON bookings;
|
||||||
|
CREATE POLICY "bookings_select_kiosk" ON bookings
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
is_kiosk() AND
|
||||||
|
location_id = get_current_kiosk_location_id() AND
|
||||||
|
status IN ('pending', 'confirmed')
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "bookings_create_kiosk" ON bookings;
|
||||||
|
CREATE POLICY "bookings_create_kiosk" ON bookings
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
is_kiosk() AND
|
||||||
|
location_id = get_current_kiosk_location_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "bookings_confirm_kiosk" ON bookings;
|
||||||
|
CREATE POLICY "bookings_confirm_kiosk" ON bookings
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
is_kiosk() AND
|
||||||
|
location_id = get_current_kiosk_location_id() AND
|
||||||
|
status = 'pending'
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
is_kiosk() AND
|
||||||
|
location_id = get_current_kiosk_location_id() AND
|
||||||
|
status = 'confirmed'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RESOURCES: Kiosk puede ver recursos disponibles de su location
|
||||||
|
DROP POLICY IF EXISTS "resources_select_kiosk" ON resources;
|
||||||
|
CREATE POLICY "resources_select_kiosk" ON resources
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
is_kiosk() AND
|
||||||
|
location_id = get_current_kiosk_location_id() AND
|
||||||
|
is_active = true
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SERVICES: Kiosk puede ver servicios activos (policy ya existe, pero agregamos comentario)
|
||||||
|
-- La policy services_select_all permite a cualquier usuario ver servicios activos
|
||||||
|
|
||||||
|
-- LOCATIONS: Kiosk solo puede ver su propia location
|
||||||
|
DROP POLICY IF EXISTS "locations_select_kiosk" ON locations;
|
||||||
|
CREATE POLICY "locations_select_kiosk" ON locations
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
is_kiosk() AND
|
||||||
|
id = get_current_kiosk_location_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CUSTOMERS: Kiosk NO puede ver datos de clientes (PII restriction)
|
||||||
|
-- No se crea policy, por lo que el acceso es denegado
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN PARA CREAR KIOSK (para admin/manager)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION create_kiosk(
|
||||||
|
p_location_id UUID,
|
||||||
|
p_device_name VARCHAR(100),
|
||||||
|
p_display_name VARCHAR(100),
|
||||||
|
p_ip_address INET DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS JSONB AS $$
|
||||||
|
DECLARE
|
||||||
|
new_kiosk_id UUID;
|
||||||
|
new_api_key VARCHAR(64);
|
||||||
|
BEGIN
|
||||||
|
IF get_current_user_role() NOT IN ('admin', 'manager') THEN
|
||||||
|
RAISE EXCEPTION 'Only admin or manager can create kiosks';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
new_api_key := generate_kiosk_api_key();
|
||||||
|
|
||||||
|
INSERT INTO kiosks (location_id, device_name, display_name, api_key, ip_address)
|
||||||
|
VALUES (p_location_id, p_device_name, p_display_name, new_api_key, p_ip_address)
|
||||||
|
RETURNING id INTO new_kiosk_id;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'kiosk_id', new_kiosk_id,
|
||||||
|
'api_key', new_api_key,
|
||||||
|
'message', 'Kiosk created successfully. Save the API key securely as it will not be shown again.'
|
||||||
|
)::JSONB;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN PARA OBTENER RECURSOS DISPONIBLES (CON PRIORIDAD)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_available_resources_with_priority(
|
||||||
|
p_location_id UUID,
|
||||||
|
p_start_time TIMESTAMPTZ,
|
||||||
|
p_end_time TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
resource_id UUID,
|
||||||
|
resource_name VARCHAR,
|
||||||
|
resource_type resource_type,
|
||||||
|
capacity INTEGER,
|
||||||
|
priority INTEGER
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
r.id AS resource_id,
|
||||||
|
r.name AS resource_name,
|
||||||
|
r.type AS resource_type,
|
||||||
|
r.capacity,
|
||||||
|
CASE r.type
|
||||||
|
WHEN 'station' THEN 1
|
||||||
|
WHEN 'room' THEN 2
|
||||||
|
WHEN 'equipment' THEN 3
|
||||||
|
END AS priority
|
||||||
|
FROM resources r
|
||||||
|
WHERE r.location_id = p_location_id
|
||||||
|
AND r.is_active = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM bookings b
|
||||||
|
WHERE b.resource_id = r.id
|
||||||
|
AND b.status NOT IN ('cancelled', 'no_show')
|
||||||
|
AND (
|
||||||
|
(b.start_time_utc < p_end_time AND b.end_time_utc > p_start_time)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY priority, r.name;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SEED DATA: KIOSKS DE PRUEBA
|
||||||
|
-- ============================================
|
||||||
|
-- Nota: Los kiosks se crearán manualmente vía UI de enrollment
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- VERIFICACIÓN
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE '===========================================';
|
||||||
|
RAISE NOTICE 'SALONOS - KIOSK IMPLEMENTATION COMPLETED';
|
||||||
|
RAISE NOTICE '===========================================';
|
||||||
|
RAISE NOTICE '✅ user_role enum updated with kiosk';
|
||||||
|
RAISE NOTICE '✅ kiosks table created';
|
||||||
|
RAISE NOTICE '✅ Indexes created';
|
||||||
|
RAISE NOTICE '✅ Functions created:';
|
||||||
|
RAISE NOTICE ' - generate_kiosk_api_key()';
|
||||||
|
RAISE NOTICE ' - get_current_kiosk_id()';
|
||||||
|
RAISE NOTICE ' - is_kiosk()';
|
||||||
|
RAISE NOTICE ' - get_current_kiosk_location_id()';
|
||||||
|
RAISE NOTICE ' - create_kiosk()';
|
||||||
|
RAISE NOTICE ' - get_available_resources_with_priority()';
|
||||||
|
RAISE NOTICE '✅ RLS policies created for kiosk';
|
||||||
|
RAISE NOTICE '===========================================';
|
||||||
|
RAISE NOTICE 'NEXT STEPS:';
|
||||||
|
RAISE NOTICE '1. Create additional kiosks using:';
|
||||||
|
RAISE NOTICE ' SELECT create_kiosk(location_id, device_name, display_name);';
|
||||||
|
RAISE NOTICE '2. Test kiosk authentication via API';
|
||||||
|
RAISE NOTICE '3. Implement API routes in Next.js';
|
||||||
|
RAISE NOTICE '===========================================';
|
||||||
|
END
|
||||||
|
$$;
|
||||||
109
supabase/migrations/20260116010000_update_resources.sql
Normal file
109
supabase/migrations/20260116010000_update_resources.sql
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- ACTUALIZACIÓN DE RECURSOS - SALONOS
|
||||||
|
-- Reemplazar recursos existentes con nueva estructura
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. ELIMINAR TODOS LOS RECURSOS EXISTENTES
|
||||||
|
DELETE FROM resources;
|
||||||
|
|
||||||
|
-- 2. CREAR NUEVOS RECURSOS PARA CADA LOCATION
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
location_record RECORD;
|
||||||
|
i INTEGER;
|
||||||
|
BEGIN
|
||||||
|
FOR location_record IN SELECT id, name FROM locations WHERE is_active = true LOOP
|
||||||
|
|
||||||
|
-- 3 Estaciones de Maquillaje (mkup)
|
||||||
|
FOR i IN 1..3 LOOP
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
VALUES (
|
||||||
|
location_record.id,
|
||||||
|
'mkup-' || LPAD(i::TEXT, 2, '0'),
|
||||||
|
'station',
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 1 Cama de Pestañas (lshs)
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
VALUES (
|
||||||
|
location_record.id,
|
||||||
|
'lshs-01',
|
||||||
|
'station',
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4 Estaciones de Pedicure (pedi)
|
||||||
|
FOR i IN 1..4 LOOP
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
VALUES (
|
||||||
|
location_record.id,
|
||||||
|
'pedi-' || LPAD(i::TEXT, 2, '0'),
|
||||||
|
'station',
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 4 Estaciones de Manicure (mani)
|
||||||
|
FOR i IN 1..4 LOOP
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
VALUES (
|
||||||
|
location_record.id,
|
||||||
|
'mani-' || LPAD(i::TEXT, 2, '0'),
|
||||||
|
'station',
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Recursos creados para location: %', location_record.name;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3. VERIFICACIÓN Y RESUMEN
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
location_record RECORD;
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'ACTUALIZACIÓN DE RECURSOS COMPLETADA';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'Total de Resources: %', (SELECT COUNT(*) FROM resources);
|
||||||
|
RAISE NOTICE 'Locations activas: %', (SELECT COUNT(*) FROM locations WHERE is_active = true);
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'Recursos por tipo:';
|
||||||
|
RAISE NOTICE ' - mkup (Maquillaje): %', (SELECT COUNT(*) FROM resources WHERE name LIKE 'mkup-%');
|
||||||
|
RAISE NOTICE ' - lshs (Pestañas): %', (SELECT COUNT(*) FROM resources WHERE name LIKE 'lshs-%');
|
||||||
|
RAISE NOTICE ' - pedi (Pedicure): %', (SELECT COUNT(*) FROM resources WHERE name LIKE 'pedi-%');
|
||||||
|
RAISE NOTICE ' - mani (Manicure): %', (SELECT COUNT(*) FROM resources WHERE name LIKE 'mani-%');
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'Recursos por location:';
|
||||||
|
|
||||||
|
FOR location_record IN
|
||||||
|
SELECT l.id, l.name, COUNT(r.id) as resource_count
|
||||||
|
FROM locations l
|
||||||
|
LEFT JOIN resources r ON r.location_id = l.id
|
||||||
|
WHERE l.is_active = true
|
||||||
|
GROUP BY l.id, l.name
|
||||||
|
ORDER BY l.name
|
||||||
|
LOOP
|
||||||
|
RAISE NOTICE ' %: % recursos', location_record.name, location_record.resource_count;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'NOMBRES DE RECURSOS CREADOS:';
|
||||||
|
RAISE NOTICE ' mkup-01, mkup-02, mkup-03 (Maquillaje)';
|
||||||
|
RAISE NOTICE ' lshs-01 (Pestañas)';
|
||||||
|
RAISE NOTICE ' pedi-01, pedi-02, pedi-03, pedi-04 (Pedicure)';
|
||||||
|
RAISE NOTICE ' mani-01, mani-02, mani-03, mani-04 (Manicure)';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'ADVERTENCIA: Todos los bookings existentes que';
|
||||||
|
RAISE NOTICE 'referenciaban los recursos anteriores han sido';
|
||||||
|
RAISE NOTICE 'eliminados en cascada por la restricción CASCADE.';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
END
|
||||||
|
$$;
|
||||||
148
supabase/migrations/20260116020000_cleanup_and_fix_resources.sql
Normal file
148
supabase/migrations/20260116020000_cleanup_and_fix_resources.sql
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- LIMPIEZA Y REESTRUCTURACIÓN DE RECURSOS
|
||||||
|
-- Elimina duplicados y establece formato estándar
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. OBTENER INFORMACIÓN DE LO QUE SERÁ ELIMINADO
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
resources_count INTEGER;
|
||||||
|
locations_count INTEGER;
|
||||||
|
bookings_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO resources_count FROM resources;
|
||||||
|
SELECT COUNT(*) INTO locations_count FROM locations WHERE is_active = true;
|
||||||
|
SELECT COUNT(*) INTO bookings_count FROM bookings;
|
||||||
|
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'ANTES DE LA MIGRACIÓN:';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'Resources existentes: %', resources_count;
|
||||||
|
RAISE NOTICE 'Locations activas: %', locations_count;
|
||||||
|
RAISE NOTICE 'Bookings activos: %', bookings_count;
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'ADVERTENCIA: Todos estos recursos';
|
||||||
|
RAISE NOTICE 'serán eliminados.';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- 2. ELIMINAR TODOS LOS RECURSOS EXISTENTES
|
||||||
|
DELETE FROM resources;
|
||||||
|
|
||||||
|
-- 3. CREAR NUEVOS RECURSOS PARA CADA LOCATION ACTIVA
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
location_record RECORD;
|
||||||
|
i INTEGER;
|
||||||
|
resource_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR location_record IN SELECT id, name FROM locations WHERE is_active = true LOOP
|
||||||
|
|
||||||
|
RAISE NOTICE 'Creando recursos para: %', location_record.name;
|
||||||
|
|
||||||
|
-- 3 Estaciones de Maquillaje (mkup)
|
||||||
|
FOR i IN 1..3 LOOP
|
||||||
|
resource_name := 'mkup-' || LPAD(i::TEXT, 2, '0');
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
VALUES (
|
||||||
|
location_record.id,
|
||||||
|
resource_name,
|
||||||
|
'station',
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
RAISE NOTICE ' - Creado: %', resource_name;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 1 Cama de Pestañas (lshs)
|
||||||
|
resource_name := 'lshs-01';
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
VALUES (
|
||||||
|
location_record.id,
|
||||||
|
resource_name,
|
||||||
|
'station',
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
RAISE NOTICE ' - Creado: %', resource_name;
|
||||||
|
|
||||||
|
-- 4 Estaciones de Pedicure (pedi)
|
||||||
|
FOR i IN 1..4 LOOP
|
||||||
|
resource_name := 'pedi-' || LPAD(i::TEXT, 2, '0');
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
VALUES (
|
||||||
|
location_record.id,
|
||||||
|
resource_name,
|
||||||
|
'station',
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
RAISE NOTICE ' - Creado: %', resource_name;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 4 Estaciones de Manicure (mani)
|
||||||
|
FOR i IN 1..4 LOOP
|
||||||
|
resource_name := 'mani-' || LPAD(i::TEXT, 2, '0');
|
||||||
|
INSERT INTO resources (location_id, name, type, capacity, is_active)
|
||||||
|
VALUES (
|
||||||
|
location_record.id,
|
||||||
|
resource_name,
|
||||||
|
'station',
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
RAISE NOTICE ' - Creado: %', resource_name;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Completado para location: %', location_record.name;
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 4. VERIFICACIÓN Y RESUMEN
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
total_resources INTEGER;
|
||||||
|
total_locations INTEGER;
|
||||||
|
location_record RECORD;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO total_resources FROM resources;
|
||||||
|
SELECT COUNT(*) INTO total_locations FROM locations WHERE is_active = true;
|
||||||
|
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'MIGRACIÓN DE RECURSOS COMPLETADA';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'Total de Resources: %', total_resources;
|
||||||
|
RAISE NOTICE 'Total de Locations: %', total_locations;
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'Recursos por tipo (global):';
|
||||||
|
RAISE NOTICE ' - mkup (Maquillaje): %', (SELECT COUNT(*) FROM resources WHERE name LIKE 'mkup-%');
|
||||||
|
RAISE NOTICE ' - lshs (Pestañas): %', (SELECT COUNT(*) FROM resources WHERE name LIKE 'lshs-%');
|
||||||
|
RAISE NOTICE ' - pedi (Pedicure): %', (SELECT COUNT(*) FROM resources WHERE name LIKE 'pedi-%');
|
||||||
|
RAISE NOTICE ' - mani (Manicure): %', (SELECT COUNT(*) FROM resources WHERE name LIKE 'mani-%');
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'Recursos por location:';
|
||||||
|
|
||||||
|
FOR location_record IN
|
||||||
|
SELECT l.id, l.name, COUNT(r.id) as resource_count
|
||||||
|
FROM locations l
|
||||||
|
JOIN resources r ON r.location_id = l.id
|
||||||
|
WHERE l.is_active = true
|
||||||
|
GROUP BY l.id, l.name
|
||||||
|
ORDER BY l.name
|
||||||
|
LOOP
|
||||||
|
RAISE NOTICE ' % (% recursos)', location_record.name, location_record.resource_count;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'FORMATO DE NOMBRES:';
|
||||||
|
RAISE NOTICE ' Maquillaje: mkup-01, mkup-02, mkup-03';
|
||||||
|
RAISE NOTICE ' Pestañas: lshs-01';
|
||||||
|
RAISE NOTICE ' Pedicure: pedi-01, pedi-02, pedi-03, pedi-04';
|
||||||
|
RAISE NOTICE ' Manicure: mani-01, mani-02, mani-03, mani-04';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'ESTADO: Listo para usar';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
END
|
||||||
|
$$;
|
||||||
660
supabase/migrations/20260116030000_telegram_integration.sql
Normal file
660
supabase/migrations/20260116030000_telegram_integration.sql
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- TELEGRAM INTEGRATION Y SCORING SYSTEM
|
||||||
|
-- Agrega campos de Telegram y sistema de métricas
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- AGREGAR CAMPOS DE TELEGRAM A STAFF
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'telegram_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN telegram_id BIGINT;
|
||||||
|
|
||||||
|
CREATE INDEX idx_staff_telegram ON staff(telegram_id);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added telegram_id to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'email'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN email VARCHAR(255);
|
||||||
|
|
||||||
|
CREATE INDEX idx_staff_email ON staff(email);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added email to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'gmail'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN gmail VARCHAR(255);
|
||||||
|
|
||||||
|
CREATE INDEX idx_staff_gmail ON staff(gmail);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added gmail to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'google_account'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN google_account VARCHAR(255);
|
||||||
|
|
||||||
|
CREATE INDEX idx_staff_google ON staff(google_account);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added google_account to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'telegram_chat_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN telegram_chat_id BIGINT;
|
||||||
|
|
||||||
|
CREATE INDEX idx_staff_telegram_chat ON staff(telegram_chat_id);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added telegram_chat_id to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'telegram_notifications_enabled'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN telegram_notifications_enabled BOOLEAN DEFAULT true;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added telegram_notifications_enabled to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- AGREGAR CAMPOS DE SCORING A STAFF
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'total_bookings_completed'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN total_bookings_completed INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added total_bookings_completed to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'total_guarantees_count'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN total_guarantees_count INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added total_guarantees_count to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'total_guarantees_amount'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN total_guarantees_amount DECIMAL(10,2) DEFAULT 0.00;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added total_guarantees_amount to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'performance_score'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN performance_score DECIMAL(5,2) DEFAULT 0.00;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added performance_score to staff';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'staff'
|
||||||
|
AND column_name = 'last_performance_update'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE staff ADD COLUMN last_performance_update TIMESTAMPTZ;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Added last_performance_update to staff';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CREAR TABLA TELEGRAM_NOTIFICATIONS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
recipient_type VARCHAR(50) NOT NULL, -- 'staff', 'group', 'all'
|
||||||
|
recipient_id UUID, -- ID del staff si recipient_type = 'staff'
|
||||||
|
telegram_chat_id BIGINT NOT NULL, -- Chat ID de Telegram
|
||||||
|
message_type VARCHAR(50) NOT NULL, -- 'booking_created', 'booking_confirmed', 'booking_completed', 'guarantee_processed'
|
||||||
|
message_content TEXT NOT NULL,
|
||||||
|
booking_id UUID, -- Referencia opcional al booking
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'sent', 'failed'
|
||||||
|
error_message TEXT,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CREAR TABLA TELEGRAM_GROUPS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
||||||
|
group_name VARCHAR(100) NOT NULL,
|
||||||
|
telegram_chat_id BIGINT UNIQUE NOT NULL,
|
||||||
|
group_type VARCHAR(50) NOT NULL, -- 'general', 'artists', 'management', 'alerts'
|
||||||
|
notifications_enabled BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CREAR TABLA TELEGRAM_BOTS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_bots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
bot_name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
bot_token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
bot_username VARCHAR(100) NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ÍNDICES PARA TABLAS TELEGRAM
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_notifications_recipient ON telegram_notifications(recipient_type, recipient_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_notifications_status ON telegram_notifications(status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_notifications_booking ON telegram_notifications(booking_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_groups_location ON telegram_groups(location_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_groups_type ON telegram_groups(group_type);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN: ACTUALIZAR SCORING DE STAFF
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_staff_performance_score(p_staff_id UUID)
|
||||||
|
RETURNS JSONB AS $$
|
||||||
|
DECLARE
|
||||||
|
v_staff_record RECORD;
|
||||||
|
v_completed_bookings INTEGER;
|
||||||
|
v_guarantees_count INTEGER;
|
||||||
|
v_guarantees_amount DECIMAL(10,2);
|
||||||
|
v_performance_score DECIMAL(5,2);
|
||||||
|
BEGIN
|
||||||
|
-- Obtener datos actuales del staff
|
||||||
|
SELECT * INTO v_staff_record
|
||||||
|
FROM staff
|
||||||
|
WHERE id = p_staff_id;
|
||||||
|
|
||||||
|
-- Contar bookings completados en el último mes
|
||||||
|
SELECT COUNT(*) INTO v_completed_bookings
|
||||||
|
FROM bookings
|
||||||
|
WHERE staff_id = p_staff_id
|
||||||
|
AND status = 'completed'
|
||||||
|
AND created_at >= NOW() - INTERVAL '30 days';
|
||||||
|
|
||||||
|
-- Contar garantías procesadas (simulamos por bookings de servicios con garantía)
|
||||||
|
-- En un sistema real, esto vendría de una tabla de garantías
|
||||||
|
SELECT
|
||||||
|
COUNT(*) INTO v_guarantees_count,
|
||||||
|
COALESCE(SUM(b.total_amount * 0.1), 0) INTO v_guarantees_amount
|
||||||
|
FROM bookings b
|
||||||
|
JOIN services s ON s.id = b.service_id
|
||||||
|
WHERE b.staff_id = p_staff_id
|
||||||
|
AND b.status = 'completed'
|
||||||
|
AND b.created_at >= NOW() - INTERVAL '30 days'
|
||||||
|
AND (s.name ILIKE '%garant%' OR s.description ILIKE '%garant%');
|
||||||
|
|
||||||
|
-- Calcular score de desempeño (base 100)
|
||||||
|
-- +10 por cada booking completado
|
||||||
|
-- +5 por cada garantía procesada
|
||||||
|
-- +1 por cada $100 en garantías
|
||||||
|
v_performance_score := 50.00 +
|
||||||
|
(v_completed_bookings * 10.00) +
|
||||||
|
(v_guarantees_count * 5.00) +
|
||||||
|
(v_guarantees_amount / 100.00 * 1.00);
|
||||||
|
|
||||||
|
-- Limitar score entre 0 y 100
|
||||||
|
v_performance_score := LEAST(v_performance_score, 100.00);
|
||||||
|
v_performance_score := GREATEST(v_performance_score, 0.00);
|
||||||
|
|
||||||
|
-- Actualizar staff
|
||||||
|
UPDATE staff SET
|
||||||
|
total_bookings_completed = v_completed_bookings,
|
||||||
|
total_guarantees_count = v_guarantees_count,
|
||||||
|
total_guarantees_amount = v_guarantees_amount,
|
||||||
|
performance_score = v_performance_score,
|
||||||
|
last_performance_update = NOW()
|
||||||
|
WHERE id = p_staff_id;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'staff_id', p_staff_id,
|
||||||
|
'completed_bookings', v_completed_bookings,
|
||||||
|
'guarantees_count', v_guarantees_count,
|
||||||
|
'guarantees_amount', v_guarantees_amount,
|
||||||
|
'performance_score', v_performance_score
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TRIGGER: ACTUALIZAR SCORING AL COMPLETAR BOOKING
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION trigger_update_staff_performance()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'completed' AND (OLD.status IS NULL OR OLD.status != 'completed') THEN
|
||||||
|
PERFORM update_staff_performance_score(NEW.staff_id);
|
||||||
|
|
||||||
|
IF NEW.secondary_artist_id IS NOT NULL THEN
|
||||||
|
PERFORM update_staff_performance_score(NEW.secondary_artist_id);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_performance_on_booking_complete ON bookings;
|
||||||
|
CREATE TRIGGER update_performance_on_booking_complete
|
||||||
|
AFTER UPDATE OF status ON bookings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION trigger_update_staff_performance();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN: ENVIAR NOTIFICACIÓN TELEGRAM
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION create_telegram_notification(
|
||||||
|
p_recipient_type VARCHAR(50),
|
||||||
|
p_recipient_id UUID,
|
||||||
|
p_telegram_chat_id BIGINT,
|
||||||
|
p_message_type VARCHAR(50),
|
||||||
|
p_message_content TEXT,
|
||||||
|
p_booking_id UUID DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_notification_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO telegram_notifications (
|
||||||
|
recipient_type,
|
||||||
|
recipient_id,
|
||||||
|
telegram_chat_id,
|
||||||
|
message_type,
|
||||||
|
message_content,
|
||||||
|
booking_id,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
p_recipient_type,
|
||||||
|
p_recipient_id,
|
||||||
|
p_telegram_chat_id,
|
||||||
|
p_message_type,
|
||||||
|
p_message_content,
|
||||||
|
p_booking_id,
|
||||||
|
'pending'
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_notification_id;
|
||||||
|
|
||||||
|
RETURN v_notification_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TRIGGER: NOTIFICAR CREACIÓN DE BOOKING
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_booking_created()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_staff_telegram_id BIGINT;
|
||||||
|
v_message TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Solo notificar si el staff tiene telegram configurado
|
||||||
|
SELECT telegram_chat_id INTO v_staff_telegram_id
|
||||||
|
FROM staff
|
||||||
|
WHERE id = NEW.staff_id
|
||||||
|
AND telegram_chat_id IS NOT NULL
|
||||||
|
AND telegram_notifications_enabled = true;
|
||||||
|
|
||||||
|
IF v_staff_telegram_id IS NOT NULL THEN
|
||||||
|
v_message := format('📅 NUEVA CITA ASIGNADA!%sCliente: %s%sServicio: %s%sHora: %s',
|
||||||
|
E'\n',
|
||||||
|
COALESCE((SELECT display_name FROM customers WHERE id = NEW.customer_id), 'Cliente'),
|
||||||
|
E'\n',
|
||||||
|
(SELECT name FROM services WHERE id = NEW.service_id),
|
||||||
|
E'\n',
|
||||||
|
to_char(NEW.start_time_utc, 'DD/MM/YYYY HH24:MI')
|
||||||
|
);
|
||||||
|
|
||||||
|
PERFORM create_telegram_notification(
|
||||||
|
'staff',
|
||||||
|
NEW.staff_id,
|
||||||
|
v_staff_telegram_id,
|
||||||
|
'booking_created',
|
||||||
|
v_message,
|
||||||
|
NEW.id
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS notify_booking_created_trigger ON bookings;
|
||||||
|
CREATE TRIGGER notify_booking_created_trigger
|
||||||
|
AFTER INSERT ON bookings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notify_booking_created();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TRIGGER: NOTIFICAR CONFIRMACIÓN DE BOOKING
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_booking_confirmed()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_staff_telegram_id BIGINT;
|
||||||
|
v_message TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'confirmed' AND OLD.status = 'pending' THEN
|
||||||
|
SELECT telegram_chat_id INTO v_staff_telegram_id
|
||||||
|
FROM staff
|
||||||
|
WHERE id = NEW.staff_id
|
||||||
|
AND telegram_chat_id IS NOT NULL
|
||||||
|
AND telegram_notifications_enabled = true;
|
||||||
|
|
||||||
|
IF v_staff_telegram_id IS NOT NULL THEN
|
||||||
|
v_message := format('✅ CITA CONFIRMADA!%sCódigo: %s%sCliente: %s%sHora: %s',
|
||||||
|
E'\n',
|
||||||
|
NEW.short_id,
|
||||||
|
E'\n',
|
||||||
|
COALESCE((SELECT display_name FROM customers WHERE id = NEW.customer_id), 'Cliente'),
|
||||||
|
E'\n',
|
||||||
|
to_char(NEW.start_time_utc, 'DD/MM/YYYY HH24:MI')
|
||||||
|
);
|
||||||
|
|
||||||
|
PERFORM create_telegram_notification(
|
||||||
|
'staff',
|
||||||
|
NEW.staff_id,
|
||||||
|
v_staff_telegram_id,
|
||||||
|
'booking_confirmed',
|
||||||
|
v_message,
|
||||||
|
NEW.id
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS notify_booking_confirmed_trigger ON bookings;
|
||||||
|
CREATE TRIGGER notify_booking_confirmed_trigger
|
||||||
|
AFTER UPDATE OF status ON bookings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notify_booking_confirmed();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TRIGGER: NOTIFICAR COMPLETADO DE BOOKING
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_booking_completed()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_staff_telegram_id BIGINT;
|
||||||
|
v_message TEXT;
|
||||||
|
v_score_info JSONB;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'completed' AND (OLD.status IS NULL OR OLD.status != 'completed') THEN
|
||||||
|
SELECT telegram_chat_id INTO v_staff_telegram_id
|
||||||
|
FROM staff
|
||||||
|
WHERE id = NEW.staff_id
|
||||||
|
AND telegram_chat_id IS NOT NULL
|
||||||
|
AND telegram_notifications_enabled = true;
|
||||||
|
|
||||||
|
IF v_staff_telegram_id IS NOT NULL THEN
|
||||||
|
v_message := format('💅 CITA COMPLETADA!%sCódigo: %s%sCliente: %s%sServicio: %s%sTotal: $%s',
|
||||||
|
E'\n',
|
||||||
|
NEW.short_id,
|
||||||
|
E'\n',
|
||||||
|
COALESCE((SELECT display_name FROM customers WHERE id = NEW.customer_id), 'Cliente'),
|
||||||
|
E'\n',
|
||||||
|
(SELECT name FROM services WHERE id = NEW.service_id),
|
||||||
|
E'\n',
|
||||||
|
NEW.total_amount
|
||||||
|
);
|
||||||
|
|
||||||
|
PERFORM create_telegram_notification(
|
||||||
|
'staff',
|
||||||
|
NEW.staff_id,
|
||||||
|
v_staff_telegram_id,
|
||||||
|
'booking_completed',
|
||||||
|
v_message,
|
||||||
|
NEW.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enviar actualización de score
|
||||||
|
v_score_info := update_staff_performance_score(NEW.staff_id);
|
||||||
|
|
||||||
|
-- Mensaje con score
|
||||||
|
IF v_score_info IS NOT NULL THEN
|
||||||
|
v_message := format('📊 TU SCORE ACTUALIZADO!%sBookings completados: %d%sGarantías procesadas: %d ($%.2f)%sScore de desempeño: %.2f%s📈 ¡Sigue así!',
|
||||||
|
E'\n',
|
||||||
|
v_score_info->>'completed_bookings',
|
||||||
|
E'\n',
|
||||||
|
v_score_info->>'guarantees_count',
|
||||||
|
(v_score_info->>'guarantees_amount')::DECIMAL(10,2),
|
||||||
|
E'\n',
|
||||||
|
(v_score_info->>'performance_score')::DECIMAL(5,2),
|
||||||
|
E'\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
PERFORM create_telegram_notification(
|
||||||
|
'staff',
|
||||||
|
NEW.staff_id,
|
||||||
|
v_staff_telegram_id,
|
||||||
|
'performance_update',
|
||||||
|
v_message,
|
||||||
|
NEW.id
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS notify_booking_completed_trigger ON bookings;
|
||||||
|
CREATE TRIGGER notify_booking_completed_trigger
|
||||||
|
AFTER UPDATE OF status ON bookings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notify_booking_completed();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN: ENVIAR NOTIFICACIÓN A GRUPO TELEGRAM
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_telegram_group(
|
||||||
|
p_group_id UUID,
|
||||||
|
p_message_type VARCHAR(50),
|
||||||
|
p_message_content TEXT,
|
||||||
|
p_booking_id UUID DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_group_record RECORD;
|
||||||
|
v_notification_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_group_record
|
||||||
|
FROM telegram_groups
|
||||||
|
WHERE id = p_group_id
|
||||||
|
AND notifications_enabled = true;
|
||||||
|
|
||||||
|
IF v_group_record.id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Telegram group not found or notifications disabled';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_notification_id := create_telegram_notification(
|
||||||
|
'group',
|
||||||
|
NULL,
|
||||||
|
v_group_record.telegram_chat_id,
|
||||||
|
p_message_type,
|
||||||
|
p_message_content,
|
||||||
|
p_booking_id
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN v_notification_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN: OBTENER STAFF TOP POR SCORE
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_top_performers(p_location_id UUID, p_limit INTEGER DEFAULT 10)
|
||||||
|
RETURNS TABLE (
|
||||||
|
staff_id UUID,
|
||||||
|
display_name VARCHAR,
|
||||||
|
role VARCHAR,
|
||||||
|
performance_score DECIMAL(5,2),
|
||||||
|
total_bookings_completed INTEGER,
|
||||||
|
total_guarantees_count INTEGER,
|
||||||
|
total_guarantees_amount DECIMAL(10,2),
|
||||||
|
last_performance_update TIMESTAMPTZ
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.display_name,
|
||||||
|
s.role,
|
||||||
|
s.performance_score,
|
||||||
|
s.total_bookings_completed,
|
||||||
|
s.total_guarantees_count,
|
||||||
|
s.total_guarantees_amount,
|
||||||
|
s.last_performance_update
|
||||||
|
FROM staff s
|
||||||
|
WHERE s.location_id = p_location_id
|
||||||
|
AND s.is_active = true
|
||||||
|
AND s.role IN ('artist', 'staff', 'manager')
|
||||||
|
ORDER BY s.performance_score DESC NULLS LAST
|
||||||
|
LIMIT p_limit;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- FUNCIÓN: OBTENER RESUMEN DE SCORES
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_performance_summary(p_location_id UUID)
|
||||||
|
RETURNS JSONB AS $$
|
||||||
|
DECLARE
|
||||||
|
v_summary JSONB;
|
||||||
|
BEGIN
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'top_performers', jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'staff_id', id,
|
||||||
|
'display_name', display_name,
|
||||||
|
'score', performance_score,
|
||||||
|
'bookings', total_bookings_completed,
|
||||||
|
'guarantees', total_guarantees_count,
|
||||||
|
'guarantees_amount', total_guarantees_amount
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'average_score', AVG(performance_score),
|
||||||
|
'total_bookings', SUM(total_bookings_completed),
|
||||||
|
'total_guarantees', SUM(total_guarantees_count),
|
||||||
|
'total_guarantees_amount', SUM(total_guarantees_amount),
|
||||||
|
'location_id', p_location_id
|
||||||
|
) INTO v_summary
|
||||||
|
FROM staff
|
||||||
|
WHERE location_id = p_location_id
|
||||||
|
AND is_active = true
|
||||||
|
AND role IN ('artist', 'staff', 'manager');
|
||||||
|
|
||||||
|
RETURN v_summary;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- VERIFICACIÓN
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'TELEGRAM INTEGRATION COMPLETED';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE '✅ Campos agregados a staff:';
|
||||||
|
RAISE NOTICE ' - telegram_id';
|
||||||
|
RAISE NOTICE ' - email';
|
||||||
|
RAISE NOTICE ' - gmail';
|
||||||
|
RAISE NOTICE ' - google_account';
|
||||||
|
RAISE NOTICE ' - telegram_chat_id';
|
||||||
|
RAISE NOTICE ' - telegram_notifications_enabled';
|
||||||
|
RAISE NOTICE ' - total_bookings_completed';
|
||||||
|
RAISE NOTICE ' - total_guarantees_count';
|
||||||
|
RAISE NOTICE ' - total_guarantees_amount';
|
||||||
|
RAISE NOTICE ' - performance_score';
|
||||||
|
RAISE NOTICE ' - last_performance_update';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE '✅ Nuevas tablas creadas:';
|
||||||
|
RAISE NOTICE ' - telegram_notifications';
|
||||||
|
RAISE NOTICE ' - telegram_groups';
|
||||||
|
RAISE NOTICE ' - telegram_bots';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE '✅ Funciones de scoring creadas:';
|
||||||
|
RAISE NOTICE ' - update_staff_performance_score()';
|
||||||
|
RAISE NOTICE ' - get_top_performers()';
|
||||||
|
RAISE NOTICE ' - get_performance_summary()';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE '✅ Triggers automáticos:';
|
||||||
|
RAISE NOTICE ' - Notificar al crear booking';
|
||||||
|
RAISE NOTICE ' - Notificar al confirmar booking';
|
||||||
|
RAISE NOTICE ' - Notificar al completar booking';
|
||||||
|
RAISE NOTICE ' - Actualizar score al completar booking';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
RAISE NOTICE 'PRÓXIMOS PASOS:';
|
||||||
|
RAISE NOTICE '1. Crear bot de Telegram';
|
||||||
|
RAISE NOTICE '2. Configurar webhook del bot';
|
||||||
|
RAISE NOTICE '3. Agregar grupos de Telegram';
|
||||||
|
RAISE NOTICE '4. Asignar chat IDs a staff';
|
||||||
|
RAISE NOTICE '5. Implementar API de envío de mensajes';
|
||||||
|
RAISE NOTICE '==========================================';
|
||||||
|
END
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user