feat: Implement FASE 5 (Clients & Loyalty) and FASE 6 (Payments & Financial)

FASE 5 - Clientes y Fidelización:
- Client Management (CRM) con búsqueda fonética
- Galería de fotos restringida por tier (VIP/Black/Gold)
- Sistema de Lealtad con puntos y expiración (6 meses)
- Membresías (Gold, Black, VIP) con beneficios configurables
- Notas técnicas con timestamp

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

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

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

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

Migraciones SQL:
- 20260118050000_clients_loyalty_system.sql - Clientes, fotos, lealtad, membresías
- 20260118060000_stripe_webhooks_noshow_logic.sql - Webhooks, no-shows, check-ins
- 20260118070000_financial_reporting_expenses.sql - Gastos, reportes financieros
This commit is contained in:
Marco Gallegos
2026-01-18 23:05:09 -06:00
parent f6832c1e29
commit bb25d6bde6
21 changed files with 3845 additions and 13 deletions

792
docs/APERATURE_SPECS.md Normal file
View File

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

View File

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

View File

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