From 51ecb1b231acb034fcfc23e1bec944401f71cc74 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 1 Feb 2026 08:27:32 +0000 Subject: [PATCH] feat: add settings and reports pages - Add settings page with organization, sites, courts, and users tabs - Add reports page with revenue charts and statistics - Add users API endpoint - Add sites/[id] API endpoint for CRUD operations - Add tabs UI component - Fix sites API to return isActive field Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 17 +- apps/web/app/(admin)/reports/page.tsx | 427 ++++++++++ apps/web/app/(admin)/settings/page.tsx | 812 +++++++++++++++++++ apps/web/app/api/sites/[id]/route.ts | 153 ++++ apps/web/app/api/sites/route.ts | 57 +- apps/web/app/api/users/route.ts | 136 ++++ apps/web/components/clients/client-table.tsx | 12 +- apps/web/components/ui/tabs.tsx | 54 ++ apps/web/next-env.d.ts | 5 + apps/web/next.config.js | 1 + apps/web/public/.gitkeep | 0 package-lock.json | 115 +++ pnpm-lock.yaml | 301 ++++++- scripts/init-db.sql | 8 + turbo.json | 2 +- 15 files changed, 2083 insertions(+), 17 deletions(-) create mode 100644 apps/web/app/(admin)/reports/page.tsx create mode 100644 apps/web/app/(admin)/settings/page.tsx create mode 100644 apps/web/app/api/sites/[id]/route.ts create mode 100644 apps/web/app/api/users/route.ts create mode 100644 apps/web/components/ui/tabs.tsx create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/public/.gitkeep create mode 100644 package-lock.json create mode 100644 scripts/init-db.sql diff --git a/Dockerfile b/Dockerfile index a03a848..12cdd6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ # Stage 1: Dependencias # ============================================ FROM node:20-alpine AS deps -RUN apk add --no-cache libc6-compat +RUN apk add --no-cache libc6-compat openssl WORKDIR /app @@ -24,7 +24,7 @@ RUN pnpm install --frozen-lockfile # Stage 2: Builder # ============================================ FROM node:20-alpine AS builder -RUN apk add --no-cache libc6-compat +RUN apk add --no-cache libc6-compat openssl WORKDIR /app @@ -50,6 +50,7 @@ RUN pnpm build # Stage 3: Runner (Produccion) # ============================================ FROM node:20-alpine AS runner +RUN apk add --no-cache openssl WORKDIR /app @@ -61,17 +62,15 @@ ENV NEXT_TELEMETRY_DISABLED 1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs -# Copiar archivos necesarios para produccion -COPY --from=builder /app/apps/web/public ./apps/web/public - -# Copiar archivos de Next.js standalone +# Copiar archivos de Next.js standalone (incluye node_modules necesarios) COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static +# Crear public folder +RUN mkdir -p ./apps/web/public && chown nextjs:nodejs ./apps/web/public + # Copiar schema de Prisma para migraciones COPY --from=builder /app/apps/web/prisma ./apps/web/prisma -COPY --from=builder /app/apps/web/node_modules/.prisma ./apps/web/node_modules/.prisma -COPY --from=builder /app/apps/web/node_modules/@prisma ./apps/web/node_modules/@prisma # Cambiar a usuario no-root USER nextjs @@ -83,4 +82,4 @@ ENV PORT 3000 ENV HOSTNAME "0.0.0.0" # Comando de inicio -CMD ["node", "apps/web/server.js"] +CMD ["node", "server.js"] diff --git a/apps/web/app/(admin)/reports/page.tsx b/apps/web/app/(admin)/reports/page.tsx new file mode 100644 index 0000000..fb16cd3 --- /dev/null +++ b/apps/web/app/(admin)/reports/page.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + BarChart3, + TrendingUp, + TrendingDown, + Calendar, + DollarSign, + Users, + Clock, + Download, + Filter, +} from "lucide-react"; + +interface ReportStats { + totalRevenue: number; + totalBookings: number; + totalClients: number; + avgOccupancy: number; + revenueChange: number; + bookingsChange: number; + clientsChange: number; + occupancyChange: number; +} + +interface DailyRevenue { + date: string; + bookings: number; + sales: number; + total: number; +} + +interface TopProduct { + name: string; + quantity: number; + revenue: number; +} + +interface CourtStats { + name: string; + site: string; + bookings: number; + revenue: number; + occupancy: number; +} + +export default function ReportsPage() { + const [dateRange, setDateRange] = useState("month"); + const [selectedSite, setSelectedSite] = useState("all"); + const [loading, setLoading] = useState(true); + + const [stats, setStats] = useState({ + totalRevenue: 0, + totalBookings: 0, + totalClients: 0, + avgOccupancy: 0, + revenueChange: 0, + bookingsChange: 0, + clientsChange: 0, + occupancyChange: 0, + }); + + const [dailyRevenue, setDailyRevenue] = useState([]); + const [topProducts, setTopProducts] = useState([]); + const [courtStats, setCourtStats] = useState([]); + + useEffect(() => { + fetchReportData(); + }, [dateRange, selectedSite]); + + const fetchReportData = async () => { + setLoading(true); + + // Simulated data - in production, this would come from API + await new Promise((resolve) => setTimeout(resolve, 500)); + + setStats({ + totalRevenue: 125840, + totalBookings: 342, + totalClients: 156, + avgOccupancy: 68, + revenueChange: 12.5, + bookingsChange: 8.3, + clientsChange: 15.2, + occupancyChange: -2.1, + }); + + setDailyRevenue([ + { date: "Lun", bookings: 4200, sales: 1800, total: 6000 }, + { date: "Mar", bookings: 3800, sales: 1200, total: 5000 }, + { date: "Mié", bookings: 4500, sales: 2100, total: 6600 }, + { date: "Jue", bookings: 5200, sales: 1900, total: 7100 }, + { date: "Vie", bookings: 6800, sales: 3200, total: 10000 }, + { date: "Sáb", bookings: 8500, sales: 4100, total: 12600 }, + { date: "Dom", bookings: 7200, sales: 3500, total: 10700 }, + ]); + + setTopProducts([ + { name: "Agua", quantity: 245, revenue: 4900 }, + { name: "Gatorade", quantity: 180, revenue: 6300 }, + { name: "Cerveza", quantity: 156, revenue: 7020 }, + { name: "Pelotas HEAD", quantity: 42, revenue: 7560 }, + { name: "Raqueta alquiler", quantity: 38, revenue: 3800 }, + ]); + + setCourtStats([ + { name: "Cancha 1", site: "Sede Norte", bookings: 68, revenue: 20400, occupancy: 72 }, + { name: "Cancha 2", site: "Sede Norte", bookings: 54, revenue: 16200, occupancy: 58 }, + { name: "Cancha 1", site: "Sede Centro", bookings: 72, revenue: 21600, occupancy: 76 }, + { name: "Cancha 2", site: "Sede Centro", bookings: 61, revenue: 18300, occupancy: 65 }, + { name: "Cancha 1", site: "Sede Sur", bookings: 48, revenue: 14400, occupancy: 51 }, + { name: "Cancha 2", site: "Sede Sur", bookings: 39, revenue: 11700, occupancy: 42 }, + ]); + + setLoading(false); + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("es-MX", { + style: "currency", + currency: "MXN", + minimumFractionDigits: 0, + }).format(amount); + }; + + const maxRevenue = Math.max(...dailyRevenue.map((d) => d.total)); + + return ( +
+ {/* Header */} +
+
+

Reportes

+

Análisis y estadísticas del negocio

+
+
+ + +
+
+ + {/* KPI Cards */} +
+ + + + +
+ + {/* Charts Row */} +
+ {/* Revenue Chart */} + + + + + Ingresos por Día + + + + {loading ? ( +
+ ) : ( +
+ {dailyRevenue.map((day) => ( +
+ {day.date} +
+
+
+
+
+ + {formatCurrency(day.total)} + +
+
+ ))} +
+
+
+ Reservas +
+
+
+ Ventas +
+
+
+ )} + + + + {/* Top Products */} + + + Productos Más Vendidos + + + {loading ? ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : ( +
+ {topProducts.map((product, index) => ( +
+ + {index + 1} + +
+

{product.name}

+

{product.quantity} unidades

+
+ + {formatCurrency(product.revenue)} + +
+ ))} +
+ )} + + +
+ + {/* Courts Performance */} + + + Rendimiento por Cancha + + + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : ( +
+ + + + + + + + + + + + {courtStats.map((court, index) => ( + + + + + + + + ))} + +
CanchaSedeReservasIngresosOcupación
{court.name}{court.site}{court.bookings} + {formatCurrency(court.revenue)} + +
+
+
= 70 + ? "bg-accent" + : court.occupancy >= 50 + ? "bg-amber-500" + : "bg-red-400" + }`} + style={{ width: `${court.occupancy}%` }} + /> +
+ {court.occupancy}% +
+
+
+ )} + + + + {/* Summary Cards */} +
+ + + Mejor Día + + +

Sábado

+

+ {formatCurrency(12600)} en ingresos promedio +

+
+
+ + + Hora Pico + + +

18:00 - 20:00

+

+ 85% de ocupación en este horario +

+
+
+ + + Ticket Promedio + + +

{formatCurrency(368)}

+

+ Por visita (reserva + consumo) +

+
+
+
+
+ ); +} + +// Stat Card Component +function StatCard({ + title, + value, + change, + icon: Icon, + loading, +}: { + title: string; + value: string; + change: number; + icon: React.ComponentType<{ className?: string }>; + loading: boolean; +}) { + const isPositive = change >= 0; + + if (loading) { + return ( + + +
+
+
+ + + ); + } + + return ( + + +
+ {title} + +
+

{value}

+
+ {isPositive ? ( + + ) : ( + + )} + + {isPositive ? "+" : ""} + {change}% + + vs período anterior +
+
+
+ ); +} diff --git a/apps/web/app/(admin)/settings/page.tsx b/apps/web/app/(admin)/settings/page.tsx new file mode 100644 index 0000000..949af60 --- /dev/null +++ b/apps/web/app/(admin)/settings/page.tsx @@ -0,0 +1,812 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Building2, + MapPin, + Users, + Clock, + DollarSign, + Save, + Plus, + Pencil, + Trash2, + X, +} from "lucide-react"; + +interface Site { + id: string; + name: string; + address: string; + phone: string | null; + openTime: string; + closeTime: string; + isActive: boolean; +} + +interface Court { + id: string; + name: string; + type: string; + hourlyRate: number; + peakHourlyRate: number | null; + status: string; + siteId: string; + site?: { name: string }; +} + +interface User { + id: string; + email: string; + firstName: string; + lastName: string; + role: string; + isActive: boolean; + site?: { name: string } | null; +} + +export default function SettingsPage() { + const [activeTab, setActiveTab] = useState("organization"); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + + // Organization state + const [orgName, setOrgName] = useState("Padel Pro Demo"); + const [orgEmail, setOrgEmail] = useState("info@padelpro.com"); + const [orgPhone, setOrgPhone] = useState("+52 555 123 4567"); + const [currency, setCurrency] = useState("MXN"); + const [timezone, setTimezone] = useState("America/Mexico_City"); + + // Sites state + const [sites, setSites] = useState([]); + const [loadingSites, setLoadingSites] = useState(true); + const [editingSite, setEditingSite] = useState(null); + const [showSiteForm, setShowSiteForm] = useState(false); + + // Courts state + const [courts, setCourts] = useState([]); + const [loadingCourts, setLoadingCourts] = useState(true); + const [editingCourt, setEditingCourt] = useState(null); + const [showCourtForm, setShowCourtForm] = useState(false); + + // Users state + const [users, setUsers] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(true); + + useEffect(() => { + fetchSites(); + fetchCourts(); + fetchUsers(); + }, []); + + const fetchSites = async () => { + try { + const res = await fetch("/api/sites"); + if (res.ok) { + const data = await res.json(); + setSites(data.data || []); + } + } catch (error) { + console.error("Error fetching sites:", error); + } finally { + setLoadingSites(false); + } + }; + + const fetchCourts = async () => { + try { + const res = await fetch("/api/courts"); + if (res.ok) { + const data = await res.json(); + setCourts(data.data || []); + } + } catch (error) { + console.error("Error fetching courts:", error); + } finally { + setLoadingCourts(false); + } + }; + + const fetchUsers = async () => { + try { + const res = await fetch("/api/users"); + if (res.ok) { + const data = await res.json(); + setUsers(data.data || []); + } + } catch (error) { + console.error("Error fetching users:", error); + } finally { + setLoadingUsers(false); + } + }; + + const handleSaveOrganization = async () => { + setLoading(true); + // Simulate save + await new Promise((resolve) => setTimeout(resolve, 500)); + setMessage({ type: "success", text: "Configuración guardada correctamente" }); + setLoading(false); + setTimeout(() => setMessage(null), 3000); + }; + + const handleSaveSite = async (site: Partial) => { + setLoading(true); + try { + const method = editingSite ? "PUT" : "POST"; + const url = editingSite ? `/api/sites/${editingSite.id}` : "/api/sites"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(site), + }); + + if (res.ok) { + setMessage({ type: "success", text: editingSite ? "Sede actualizada" : "Sede creada" }); + fetchSites(); + setShowSiteForm(false); + setEditingSite(null); + } else { + setMessage({ type: "error", text: "Error al guardar la sede" }); + } + } catch (error) { + setMessage({ type: "error", text: "Error de conexión" }); + } finally { + setLoading(false); + setTimeout(() => setMessage(null), 3000); + } + }; + + const handleSaveCourt = async (court: Partial) => { + setLoading(true); + try { + const method = editingCourt ? "PUT" : "POST"; + const url = editingCourt ? `/api/courts/${editingCourt.id}` : "/api/courts"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(court), + }); + + if (res.ok) { + setMessage({ type: "success", text: editingCourt ? "Cancha actualizada" : "Cancha creada" }); + fetchCourts(); + setShowCourtForm(false); + setEditingCourt(null); + } else { + setMessage({ type: "error", text: "Error al guardar la cancha" }); + } + } catch (error) { + setMessage({ type: "error", text: "Error de conexión" }); + } finally { + setLoading(false); + setTimeout(() => setMessage(null), 3000); + } + }; + + const handleDeleteCourt = async (courtId: string) => { + if (!confirm("¿Estás seguro de eliminar esta cancha?")) return; + + try { + const res = await fetch(`/api/courts/${courtId}`, { method: "DELETE" }); + if (res.ok) { + setMessage({ type: "success", text: "Cancha eliminada" }); + fetchCourts(); + } else { + setMessage({ type: "error", text: "Error al eliminar la cancha" }); + } + } catch (error) { + setMessage({ type: "error", text: "Error de conexión" }); + } + setTimeout(() => setMessage(null), 3000); + }; + + return ( +
+ {/* Header */} +
+

Configuración

+

Administra la configuración del sistema

+
+ + {/* Message */} + {message && ( +
+ {message.text} +
+ )} + + {/* Tabs */} + + + + + Organización + + + + Sedes + + + + Canchas + + + + Usuarios + + + + {/* Organization Tab */} + + + + Información de la Organización + + +
+
+ + setOrgName(e.target.value)} + placeholder="Nombre" + /> +
+
+ + setOrgEmail(e.target.value)} + placeholder="email@ejemplo.com" + /> +
+
+ + setOrgPhone(e.target.value)} + placeholder="+52 555 123 4567" + /> +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + + + Configuración de Reservas + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+ + {/* Sites Tab */} + +
+

Sedes

+ +
+ + {loadingSites ? ( +
+ {[1, 2, 3].map((i) => ( + + +
+
+
+ + + ))} +
+ ) : ( +
+ {sites.map((site) => ( + + +
+

{site.name}

+
+ +
+
+

{site.address}

+

+ {site.openTime} - {site.closeTime} +

+ {site.phone && ( +

{site.phone}

+ )} +
+ + {site.isActive ? "Activa" : "Inactiva"} + +
+
+
+ ))} +
+ )} + + {/* Site Form Modal */} + {showSiteForm && ( + { setShowSiteForm(false); setEditingSite(null); }} + loading={loading} + /> + )} + + + {/* Courts Tab */} + +
+

Canchas

+ +
+ + {loadingCourts ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ ) : ( + + + + + + + + + + + + + + + {courts.map((court) => ( + + + + + + + + + ))} + +
CanchaSedeTipoPrecio/horaEstadoAcciones
{court.name}{court.site?.name || "-"}{court.type}${court.hourlyRate} + + {court.status === "active" ? "Activa" : court.status === "maintenance" ? "Mantenimiento" : "Inactiva"} + + + + +
+
+
+ )} + + {/* Court Form Modal */} + {showCourtForm && ( + { setShowCourtForm(false); setEditingCourt(null); }} + loading={loading} + /> + )} + + + {/* Users Tab */} + +
+

Usuarios

+ +
+ + {loadingUsers ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ ) : ( + + + + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
UsuarioEmailRolSedeEstadoAcciones
+ {user.firstName} {user.lastName} + {user.email} + + {user.role === "super_admin" ? "Super Admin" : + user.role === "site_admin" ? "Admin Sede" : + user.role === "staff" ? "Staff" : user.role} + + {user.site?.name || "Todas"} + + {user.isActive ? "Activo" : "Inactivo"} + + + +
+
+
+ )} + + +
+ ); +} + +// Site Form Modal Component +function SiteFormModal({ + site, + onSave, + onClose, + loading, +}: { + site: Site | null; + onSave: (site: Partial) => void; + onClose: () => void; + loading: boolean; +}) { + const [name, setName] = useState(site?.name || ""); + const [address, setAddress] = useState(site?.address || ""); + const [phone, setPhone] = useState(site?.phone || ""); + const [openTime, setOpenTime] = useState(site?.openTime || "08:00"); + const [closeTime, setCloseTime] = useState(site?.closeTime || "22:00"); + const [isActive, setIsActive] = useState(site?.isActive ?? true); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave({ name, address, phone, openTime, closeTime, isActive }); + }; + + return ( +
+
+
+

+ {site ? "Editar Sede" : "Nueva Sede"} +

+ +
+
+
+ + setName(e.target.value)} required /> +
+
+ + setAddress(e.target.value)} required /> +
+
+ + setPhone(e.target.value)} /> +
+
+
+ + setOpenTime(e.target.value)} /> +
+
+ + setCloseTime(e.target.value)} /> +
+
+
+ setIsActive(e.target.checked)} + className="rounded border-primary-300" + /> + +
+
+ + +
+
+
+
+ ); +} + +// Court Form Modal Component +function CourtFormModal({ + court, + sites, + onSave, + onClose, + loading, +}: { + court: Court | null; + sites: Site[]; + onSave: (court: Partial) => void; + onClose: () => void; + loading: boolean; +}) { + const [name, setName] = useState(court?.name || ""); + const [siteId, setSiteId] = useState(court?.siteId || sites[0]?.id || ""); + const [type, setType] = useState(court?.type || "indoor"); + const [hourlyRate, setHourlyRate] = useState(court?.hourlyRate?.toString() || "300"); + const [peakHourlyRate, setPeakHourlyRate] = useState(court?.peakHourlyRate?.toString() || ""); + const [status, setStatus] = useState(court?.status || "active"); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave({ + name, + siteId, + type, + hourlyRate: parseFloat(hourlyRate), + peakHourlyRate: peakHourlyRate ? parseFloat(peakHourlyRate) : null, + status, + }); + }; + + return ( +
+
+
+

+ {court ? "Editar Cancha" : "Nueva Cancha"} +

+ +
+
+
+ + setName(e.target.value)} placeholder="Cancha 1" required /> +
+
+ + +
+
+ + +
+
+
+ + setHourlyRate(e.target.value)} + min="0" + required + /> +
+
+ + setPeakHourlyRate(e.target.value)} + min="0" + placeholder="Opcional" + /> +
+
+
+ + +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/app/api/sites/[id]/route.ts b/apps/web/app/api/sites/[id]/route.ts new file mode 100644 index 0000000..8b72646 --- /dev/null +++ b/apps/web/app/api/sites/[id]/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +// GET /api/sites/[id] - Get a single site +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const site = await db.site.findFirst({ + where: { + id: params.id, + organizationId: session.user.organizationId, + }, + include: { + courts: { + where: { isActive: true }, + select: { + id: true, + name: true, + type: true, + status: true, + pricePerHour: true, + }, + }, + }, + }); + + if (!site) { + return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 }); + } + + return NextResponse.json({ data: site }); + } catch (error) { + console.error('Error fetching site:', error); + return NextResponse.json( + { error: 'Error al obtener sede' }, + { status: 500 } + ); + } +} + +// PUT /api/sites/[id] - Update a site +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!['super_admin', 'site_admin'].includes(session.user.role)) { + return NextResponse.json({ error: 'Sin permisos' }, { status: 403 }); + } + + const body = await request.json(); + const { name, address, phone, openTime, closeTime, isActive } = body; + + // Verify site belongs to organization + const existingSite = await db.site.findFirst({ + where: { + id: params.id, + organizationId: session.user.organizationId, + }, + }); + + if (!existingSite) { + return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 }); + } + + const updateData: any = {}; + if (name !== undefined) { + updateData.name = name; + updateData.slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + } + if (address !== undefined) updateData.address = address; + if (phone !== undefined) updateData.phone = phone; + if (openTime !== undefined) updateData.openTime = openTime; + if (closeTime !== undefined) updateData.closeTime = closeTime; + if (isActive !== undefined) updateData.isActive = isActive; + + const site = await db.site.update({ + where: { id: params.id }, + data: updateData, + }); + + return NextResponse.json({ data: site }); + } catch (error) { + console.error('Error updating site:', error); + return NextResponse.json( + { error: 'Error al actualizar sede' }, + { status: 500 } + ); + } +} + +// DELETE /api/sites/[id] - Delete a site (soft delete) +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (session.user.role !== 'super_admin') { + return NextResponse.json({ error: 'Sin permisos' }, { status: 403 }); + } + + // Verify site belongs to organization + const existingSite = await db.site.findFirst({ + where: { + id: params.id, + organizationId: session.user.organizationId, + }, + }); + + if (!existingSite) { + return NextResponse.json({ error: 'Sede no encontrada' }, { status: 404 }); + } + + // Soft delete + await db.site.update({ + where: { id: params.id }, + data: { isActive: false }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting site:', error); + return NextResponse.json( + { error: 'Error al eliminar sede' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/sites/route.ts b/apps/web/app/api/sites/route.ts index fdd47ed..c850959 100644 --- a/apps/web/app/api/sites/route.ts +++ b/apps/web/app/api/sites/route.ts @@ -18,7 +18,6 @@ export async function GET(request: NextRequest) { const sites = await db.site.findMany({ where: { organizationId: session.user.organizationId, - isActive: true, }, select: { id: true, @@ -30,6 +29,7 @@ export async function GET(request: NextRequest) { timezone: true, openTime: true, closeTime: true, + isActive: true, _count: { select: { courts: { @@ -56,10 +56,11 @@ export async function GET(request: NextRequest) { timezone: site.timezone, openTime: site.openTime, closeTime: site.closeTime, + isActive: site.isActive, courtCount: site._count.courts, })); - return NextResponse.json(transformedSites); + return NextResponse.json({ data: transformedSites }); } catch (error) { console.error('Error fetching sites:', error); return NextResponse.json( @@ -68,3 +69,55 @@ export async function GET(request: NextRequest) { ); } } + +// POST /api/sites - Create a new site +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!['super_admin', 'site_admin'].includes(session.user.role)) { + return NextResponse.json({ error: 'Sin permisos' }, { status: 403 }); + } + + const body = await request.json(); + const { name, address, phone, openTime, closeTime, isActive } = body; + + if (!name || !address) { + return NextResponse.json( + { error: 'Nombre y dirección son requeridos' }, + { status: 400 } + ); + } + + // Generate slug from name + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + const site = await db.site.create({ + data: { + name, + slug, + address, + phone: phone || null, + openTime: openTime || '08:00', + closeTime: closeTime || '22:00', + isActive: isActive ?? true, + organizationId: session.user.organizationId, + }, + }); + + return NextResponse.json({ data: site }, { status: 201 }); + } catch (error) { + console.error('Error creating site:', error); + return NextResponse.json( + { error: 'Error al crear sede' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/users/route.ts b/apps/web/app/api/users/route.ts new file mode 100644 index 0000000..fdf10c6 --- /dev/null +++ b/apps/web/app/api/users/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { db } from "@/lib/db"; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + const users = await db.user.findMany({ + where: { + organizationId: session.user.organizationId, + }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + isActive: true, + createdAt: true, + sites: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Transform to match the expected format + const transformedUsers = users.map((user) => ({ + ...user, + site: user.sites.length > 0 ? user.sites[0] : null, + })); + + return NextResponse.json({ data: transformedUsers }); + } catch (error) { + console.error("Error fetching users:", error); + return NextResponse.json( + { error: "Error al obtener usuarios" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + } + + // Only super_admin and site_admin can create users + if (!["super_admin", "site_admin"].includes(session.user.role)) { + return NextResponse.json({ error: "Sin permisos" }, { status: 403 }); + } + + const body = await request.json(); + const { email, password, firstName, lastName, role, siteId } = body; + + if (!email || !password || !firstName || !lastName || !role) { + return NextResponse.json( + { error: "Faltan campos requeridos" }, + { status: 400 } + ); + } + + // Check if user already exists + const existingUser = await db.user.findFirst({ + where: { + organizationId: session.user.organizationId, + email, + }, + }); + + if (existingUser) { + return NextResponse.json( + { error: "El email ya está registrado" }, + { status: 400 } + ); + } + + // Hash password + const bcrypt = require("bcryptjs"); + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await db.user.create({ + data: { + email, + password: hashedPassword, + firstName, + lastName, + role, + organizationId: session.user.organizationId, + siteIds: siteId ? [siteId] : [], + isActive: true, + }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + isActive: true, + sites: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + return NextResponse.json({ + data: { + ...user, + site: user.sites.length > 0 ? user.sites[0] : null, + }, + }, { status: 201 }); + } catch (error) { + console.error("Error creating user:", error); + return NextResponse.json( + { error: "Error al crear usuario" }, + { status: 500 } + ); + } +} diff --git a/apps/web/components/clients/client-table.tsx b/apps/web/components/clients/client-table.tsx index 967ded5..ef87d05 100644 --- a/apps/web/components/clients/client-table.tsx +++ b/apps/web/components/clients/client-table.tsx @@ -12,22 +12,32 @@ interface Client { phone: string | null; avatar?: string | null; level: string | null; + notes: string | null; isActive: boolean; createdAt: string; memberships?: Array<{ id: string; status: string; - remainingHours: number | null; + startDate: string; endDate: string; + remainingHours: number | null; plan: { id: string; name: string; + price: number | string; + durationMonths: number; + courtHours: number | null; discountPercent: number | string | null; }; }>; _count?: { bookings: number; }; + stats?: { + totalBookings: number; + totalSpent: number; + balance: number; + }; } interface ClientTableProps { diff --git a/apps/web/components/ui/tabs.tsx b/apps/web/components/ui/tabs.tsx new file mode 100644 index 0000000..42c8475 --- /dev/null +++ b/apps/web/components/ui/tabs.tsx @@ -0,0 +1,54 @@ +"use client"; + +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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/next.config.js b/apps/web/next.config.js index a9a2a60..00068f8 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: "standalone", transpilePackages: ["@padel-pro/shared"], images: { remotePatterns: [ diff --git a/apps/web/public/.gitkeep b/apps/web/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a4a3f5e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": "padel-pro", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "padel-pro", + "devDependencies": { + "turbo": "^2.0.0" + } + }, + "node_modules/turbo": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.8.1.tgz", + "integrity": "sha512-pbSMlRflA0RAuk/0jnAt8pzOYh1+sKaT8nVtcs75OFGVWD0evleQRmKtHJJV42QOhaC3Hx9mUUSOom/irasbjA==", + "dev": true, + "license": "MIT", + "bin": { + "turbo": "bin/turbo" + }, + "optionalDependencies": { + "turbo-darwin-64": "2.8.1", + "turbo-darwin-arm64": "2.8.1", + "turbo-linux-64": "2.8.1", + "turbo-linux-arm64": "2.8.1", + "turbo-windows-64": "2.8.1", + "turbo-windows-arm64": "2.8.1" + } + }, + "node_modules/turbo-darwin-64": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-2.8.1.tgz", + "integrity": "sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/turbo-darwin-arm64": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-2.8.1.tgz", + "integrity": "sha512-4bCcEpGP2/aSXmeN2gl5SuAmS1q5ykjubnFvSoXjQoCKtDOV+vc4CTl/DduZzUUutCVUWXjl8OyfIQ+DGCaV4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/turbo-linux-64": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-2.8.1.tgz", + "integrity": "sha512-m99JRlWlEgXPR7mkThAbKh6jbTmWSOXM/c6rt8yd4Uxh0+wjq7+DYcQbead6aoOqmCP9akswZ8EXIv1ogKBblg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/turbo-linux-arm64": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-2.8.1.tgz", + "integrity": "sha512-AsPlza3AsavJdl2o7FE67qyv0aLfmT1XwFQGzvwpoAO6Bj7S4a03tpUchZKNuGjNAkKVProQRFnB7PgUAScFXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/turbo-windows-64": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-2.8.1.tgz", + "integrity": "sha512-GdqNO6bYShRsr79B+2G/2ssjLEp9uBTvLBJSWRtRCiac/SEmv8T6RYv9hu+h5oGbFALtnKNp6BQBw78RJURsPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/turbo-windows-arm64": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-2.8.1.tgz", + "integrity": "sha512-n40E6IpkzrShRo3yMdRpgnn1/sAbGC6tZXwyNu8fe9RsufeD7KBiaoRSvw8xLyqV3pd2yoTL2rdCXq24MnTCWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcbff5c..41ed4c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,7 +95,10 @@ importers: version: 5.22.0 tailwindcss: specifier: ^3.4.1 - version: 3.4.19 + version: 3.4.19(tsx@4.21.0) + tsx: + specifier: ^4.7.0 + version: 4.21.0 typescript: specifier: ^5.3.3 version: 5.9.3 @@ -122,6 +125,240 @@ packages: engines: {node: '>=6.9.0'} dev: false + /@esbuild/aix-ppc64@0.27.2: + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.27.2: + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.27.2: + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.27.2: + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.27.2: + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.27.2: + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.27.2: + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.27.2: + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.27.2: + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.27.2: + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.27.2: + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.27.2: + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.27.2: + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.27.2: + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.27.2: + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.27.2: + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.27.2: + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-arm64@0.27.2: + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.27.2: + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-arm64@0.27.2: + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.27.2: + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openharmony-arm64@0.27.2: + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.27.2: + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.27.2: + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.27.2: + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.27.2: + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@floating-ui/core@1.7.4: resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} dependencies: @@ -1189,6 +1426,40 @@ packages: resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} dev: true + /esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + dev: true + /escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1250,6 +1521,12 @@ packages: engines: {node: '>=6'} dev: false + /get-tsconfig@4.13.1: + resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: true + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1538,7 +1815,7 @@ packages: postcss: 8.5.6 dev: true - /postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + /postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0): resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} peerDependencies: @@ -1559,6 +1836,7 @@ packages: jiti: 1.21.7 lilconfig: 3.1.3 postcss: 8.5.6 + tsx: 4.21.0 dev: true /postcss-nested@6.2.0(postcss@8.5.6): @@ -1713,6 +1991,10 @@ packages: picomatch: 2.3.1 dev: true + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: true + /resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -1789,7 +2071,7 @@ packages: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} dev: false - /tailwindcss@3.4.19: + /tailwindcss@3.4.19(tsx@4.21.0): resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} hasBin: true @@ -1811,7 +2093,7 @@ packages: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -1857,6 +2139,17 @@ packages: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} dev: false + /tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /turbo-darwin-64@2.8.1: resolution: {integrity: sha512-FQ6Uqxty/H1Nvn1dpBe8KUlMRclTuiyNSc1PCeDL/ad7M9ykpWutB51YpMpf9ibTA32M6wLdIRf+D96W6hDAtQ==} cpu: [x64] diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..8a42aaa --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,8 @@ +-- Padel Pro Database Initialization +-- This script runs when PostgreSQL container starts for the first time + +-- Enable UUID extension if needed +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Grant privileges +GRANT ALL PRIVILEGES ON DATABASE padel_pro TO padel; diff --git a/turbo.json b/turbo.json index 48c9010..24bc062 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,7 @@ { "$schema": "https://turbo.build/schema.json", "globalDependencies": ["**/.env.*local"], - "pipeline": { + "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"]