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:
Marco Gallegos
2026-01-16 10:51:12 -06:00
parent c770d4ebf9
commit fed5cb6850
33 changed files with 6152 additions and 80 deletions

View 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>
)
}

View 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>
)
}

View 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
}