diff --git a/apps/web/app/(admin)/dashboard/page.tsx b/apps/web/app/(admin)/dashboard/page.tsx index b84e4c8..83c2a6e 100644 --- a/apps/web/app/(admin)/dashboard/page.tsx +++ b/apps/web/app/(admin)/dashboard/page.tsx @@ -1,10 +1,306 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { useEffect, useState } from "react"; +import { formatCurrency, formatDate } from "@/lib/utils"; +import { StatCard, StatCardSkeleton } from "@/components/dashboard/stat-card"; +import { OccupancyChart, OccupancyChartSkeleton } from "@/components/dashboard/occupancy-chart"; +import { RecentBookings, RecentBookingsSkeleton } from "@/components/dashboard/recent-bookings"; +import { QuickActions } from "@/components/dashboard/quick-actions"; + +interface DashboardStats { + todayBookings: number; + todayRevenue: number; + occupancyRate: number; + activeMembers: number; + pendingBookings: number; + upcomingTournaments: number; +} + +interface CourtOccupancy { + courtId: string; + courtName: string; + availableHours: number; + bookedHours: number; + occupancyPercent: number; +} + +interface RecentBooking { + id: string; + startTime: string; + endTime: string; + status: string; + court: { + id: string; + name: string; + }; + client: { + id: string; + name: string; + } | null; +} + +interface DashboardData { + stats: DashboardStats; + courtOccupancy: CourtOccupancy[]; + recentBookings: RecentBooking[]; + date: string; +} + export default function DashboardPage() { + const { data: session } = useSession(); + const [dashboardData, setDashboardData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchDashboardData() { + try { + setIsLoading(true); + setError(null); + + const response = await fetch("/api/dashboard/stats"); + + if (!response.ok) { + throw new Error("Error al cargar los datos del dashboard"); + } + + const data = await response.json(); + setDashboardData(data); + } catch (err) { + console.error("Dashboard fetch error:", err); + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setIsLoading(false); + } + } + + fetchDashboardData(); + }, []); + + const userName = session?.user?.name?.split(" ")[0] || "Usuario"; + const today = new Date(); + return ( -
-

Dashboard

-

- Bienvenido al panel de administración de Padel Pro. -

+
+ {/* Welcome Section */} +
+
+

+ Bienvenido, {userName} +

+

+ {formatDate(today)} - Panel de administracion +

+
+ {session?.user?.siteName && ( +
+ + + + + + {session.user.siteName} + +
+ )} +
+ + {/* Error State */} + {error && ( +
+
+ + + + {error} +
+
+ )} + + {/* Stats Cards Row */} +
+ {isLoading ? ( + <> + + + + + + ) : dashboardData ? ( + <> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + ) : null} +
+ + {/* Secondary Stats Row */} + {!isLoading && dashboardData && ( +
+ + + + } + /> + + + + } + /> +
+ )} + + {/* Two Column Layout: Occupancy + Recent Bookings */} +
+ {isLoading ? ( + <> + + + + ) : dashboardData ? ( + <> + + + + ) : null} +
+ + {/* Quick Actions */} +
); } diff --git a/apps/web/app/api/dashboard/stats/route.ts b/apps/web/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..8439dc9 --- /dev/null +++ b/apps/web/app/api/dashboard/stats/route.ts @@ -0,0 +1,322 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +// GET /api/dashboard/stats - Get dashboard statistics +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'No autorizado' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const siteId = searchParams.get('siteId') || session.user.siteId; + const dateParam = searchParams.get('date'); + + // Default to today + const targetDate = dateParam ? new Date(dateParam) : new Date(); + const startOfDay = new Date(targetDate); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(targetDate); + endOfDay.setHours(23, 59, 59, 999); + + // Build where clause for site filtering + interface SiteFilter { + organizationId: string; + id?: string; + } + + const siteFilter: SiteFilter = { + organizationId: session.user.organizationId, + }; + + if (siteId) { + siteFilter.id = siteId; + } + + // Get sites matching the filter + const sites = await db.site.findMany({ + where: siteFilter, + select: { id: true, openTime: true, closeTime: true }, + }); + + const siteIds = sites.map(s => s.id); + + // 1. Today's bookings count + const todayBookings = await db.booking.count({ + where: { + siteId: { in: siteIds }, + startTime: { + gte: startOfDay, + lte: endOfDay, + }, + status: { + in: ['PENDING', 'CONFIRMED', 'COMPLETED'], + }, + }, + }); + + // 2. Today's revenue (from sales + booking payments) + // Get sales from today + const todaySales = await db.sale.aggregate({ + where: { + createdBy: { + organizationId: session.user.organizationId, + }, + createdAt: { + gte: startOfDay, + lte: endOfDay, + }, + ...(siteId + ? { + cashRegister: { + siteId, + }, + } + : {}), + }, + _sum: { + total: true, + }, + }); + + // Get booking payments from today + const todayBookingPayments = await db.booking.aggregate({ + where: { + siteId: { in: siteIds }, + startTime: { + gte: startOfDay, + lte: endOfDay, + }, + status: 'COMPLETED', + }, + _sum: { + paidAmount: true, + }, + }); + + const salesTotal = Number(todaySales._sum.total || 0); + const bookingPaymentsTotal = Number(todayBookingPayments._sum.paidAmount || 0); + const todayRevenue = salesTotal + bookingPaymentsTotal; + + // 3. Calculate occupancy rate + // Get all courts for the sites + const courts = await db.court.findMany({ + where: { + siteId: { in: siteIds }, + isActive: true, + status: 'AVAILABLE', + }, + include: { + site: { + select: { + openTime: true, + closeTime: true, + }, + }, + }, + }); + + // Calculate total available hours per court for the day + let totalAvailableSlots = 0; + let bookedSlots = 0; + + for (const court of courts) { + // Parse open/close times (format: "HH:MM") + const openTime = court.site.openTime || '08:00'; + const closeTime = court.site.closeTime || '22:00'; + + const [openHour] = openTime.split(':').map(Number); + const [closeHour] = closeTime.split(':').map(Number); + + // Each slot is 1 hour + const availableHours = closeHour - openHour; + totalAvailableSlots += availableHours; + + // Count booked hours for this court today + const courtBookings = await db.booking.findMany({ + where: { + courtId: court.id, + startTime: { + gte: startOfDay, + lte: endOfDay, + }, + status: { + in: ['PENDING', 'CONFIRMED', 'COMPLETED'], + }, + }, + select: { + startTime: true, + endTime: true, + }, + }); + + // Calculate booked hours + for (const booking of courtBookings) { + const durationMs = booking.endTime.getTime() - booking.startTime.getTime(); + const durationHours = durationMs / (1000 * 60 * 60); + bookedSlots += durationHours; + } + } + + const occupancyRate = totalAvailableSlots > 0 + ? Math.round((bookedSlots / totalAvailableSlots) * 100) + : 0; + + // 4. Active memberships count + const now = new Date(); + const activeMembers = await db.membership.count({ + where: { + status: 'ACTIVE', + endDate: { + gte: now, + }, + plan: { + organizationId: session.user.organizationId, + }, + }, + }); + + // 5. Pending bookings (awaiting payment/confirmation) + const pendingBookings = await db.booking.count({ + where: { + siteId: { in: siteIds }, + status: 'PENDING', + startTime: { + gte: startOfDay, + }, + }, + }); + + // 6. Upcoming tournaments (next 30 days) + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + + const upcomingTournaments = await db.tournament.count({ + where: { + organizationId: session.user.organizationId, + ...(siteId ? { siteId } : {}), + startDate: { + gte: now, + lte: thirtyDaysFromNow, + }, + status: { + in: ['DRAFT', 'REGISTRATION_OPEN', 'REGISTRATION_CLOSED', 'IN_PROGRESS'], + }, + }, + }); + + // Get court occupancy details for chart + const courtOccupancy = await Promise.all( + courts.map(async (court) => { + const openTime = court.site.openTime || '08:00'; + const closeTime = court.site.closeTime || '22:00'; + + const [openHour] = openTime.split(':').map(Number); + const [closeHour] = closeTime.split(':').map(Number); + const availableHours = closeHour - openHour; + + const courtBookings = await db.booking.findMany({ + where: { + courtId: court.id, + startTime: { + gte: startOfDay, + lte: endOfDay, + }, + status: { + in: ['PENDING', 'CONFIRMED', 'COMPLETED'], + }, + }, + select: { + startTime: true, + endTime: true, + }, + }); + + let bookedHours = 0; + for (const booking of courtBookings) { + const durationMs = booking.endTime.getTime() - booking.startTime.getTime(); + bookedHours += durationMs / (1000 * 60 * 60); + } + + return { + courtId: court.id, + courtName: court.name, + availableHours, + bookedHours: Math.round(bookedHours * 10) / 10, + occupancyPercent: availableHours > 0 + ? Math.round((bookedHours / availableHours) * 100) + : 0, + }; + }) + ); + + // Get recent bookings for the day + const recentBookings = await db.booking.findMany({ + where: { + siteId: { in: siteIds }, + startTime: { + gte: startOfDay, + lte: endOfDay, + }, + }, + include: { + court: { + select: { + id: true, + name: true, + }, + }, + client: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + orderBy: { + startTime: 'asc', + }, + take: 10, + }); + + return NextResponse.json({ + stats: { + todayBookings, + todayRevenue, + occupancyRate, + activeMembers, + pendingBookings, + upcomingTournaments, + }, + courtOccupancy, + recentBookings: recentBookings.map((booking) => ({ + id: booking.id, + startTime: booking.startTime, + endTime: booking.endTime, + status: booking.status, + court: booking.court, + client: booking.client + ? { + id: booking.client.id, + name: `${booking.client.firstName} ${booking.client.lastName}`, + } + : null, + })), + date: targetDate.toISOString().split('T')[0], + }); + } catch (error) { + console.error('Error fetching dashboard stats:', error); + return NextResponse.json( + { error: 'Error al obtener estadísticas del dashboard' }, + { status: 500 } + ); + } +} diff --git a/apps/web/components/dashboard/index.ts b/apps/web/components/dashboard/index.ts new file mode 100644 index 0000000..0eebfbc --- /dev/null +++ b/apps/web/components/dashboard/index.ts @@ -0,0 +1,4 @@ +export { StatCard, StatCardSkeleton } from './stat-card'; +export { OccupancyChart, OccupancyChartSkeleton } from './occupancy-chart'; +export { RecentBookings, RecentBookingsSkeleton } from './recent-bookings'; +export { QuickActions } from './quick-actions'; diff --git a/apps/web/components/dashboard/occupancy-chart.tsx b/apps/web/components/dashboard/occupancy-chart.tsx new file mode 100644 index 0000000..a344eca --- /dev/null +++ b/apps/web/components/dashboard/occupancy-chart.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +interface CourtOccupancy { + courtId: string; + courtName: string; + availableHours: number; + bookedHours: number; + occupancyPercent: number; +} + +interface OccupancyChartProps { + data: CourtOccupancy[]; + isLoading?: boolean; +} + +export function OccupancyChart({ data, isLoading = false }: OccupancyChartProps) { + if (isLoading) { + return ; + } + + if (data.length === 0) { + return ( + + + + + + + Ocupacion de Canchas + + + +
+ + + +

No hay canchas configuradas

+
+
+
+ ); + } + + // Calculate overall occupancy + const totalAvailable = data.reduce((sum, c) => sum + c.availableHours, 0); + const totalBooked = data.reduce((sum, c) => sum + c.bookedHours, 0); + const overallOccupancy = totalAvailable > 0 + ? Math.round((totalBooked / totalAvailable) * 100) + : 0; + + return ( + + +
+ + + + + Ocupacion de Canchas + +
+ = 80 + ? "text-green-600" + : overallOccupancy >= 50 + ? "text-blue-600" + : "text-primary-600" + )} + > + {overallOccupancy}% + + total +
+
+
+ +
+ {data.map((court) => ( +
+
+ + {court.courtName} + + + {court.bookedHours}h / {court.availableHours}h + +
+
+ {/* Available bar (background) */} +
+ {/* Booked bar */} +
= 80 + ? "bg-blue-500" + : court.occupancyPercent >= 50 + ? "bg-blue-400" + : "bg-blue-300" + )} + style={{ width: `${court.occupancyPercent}%` }} + >
+
+
+ = 80 + ? "text-blue-600" + : court.occupancyPercent >= 50 + ? "text-blue-500" + : "text-primary-500" + )} + > + {court.occupancyPercent}% ocupado + + + {court.availableHours - court.bookedHours}h disponible + +
+
+ ))} +
+ + {/* Legend */} +
+
+
+ Ocupado +
+
+
+ Disponible +
+
+
+
+ ); +} + +// Loading skeleton +export function OccupancyChartSkeleton() { + return ( + + +
+
+
+
+
+ +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/components/dashboard/quick-actions.tsx b/apps/web/components/dashboard/quick-actions.tsx new file mode 100644 index 0000000..e310e11 --- /dev/null +++ b/apps/web/components/dashboard/quick-actions.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +interface QuickAction { + label: string; + href: string; + icon: React.ReactNode; + color: string; + description: string; +} + +const quickActions: QuickAction[] = [ + { + label: "Nueva Reserva", + href: "/bookings", + icon: ( + + + + ), + color: "bg-blue-500 hover:bg-blue-600", + description: "Crear una nueva reserva de cancha", + }, + { + label: "Abrir Caja", + href: "/pos", + icon: ( + + + + ), + color: "bg-green-500 hover:bg-green-600", + description: "Iniciar turno de caja registradora", + }, + { + label: "Nueva Venta", + href: "/pos", + icon: ( + + + + ), + color: "bg-purple-500 hover:bg-purple-600", + description: "Registrar venta en el punto de venta", + }, + { + label: "Registrar Cliente", + href: "/clients", + icon: ( + + + + ), + color: "bg-orange-500 hover:bg-orange-600", + description: "Agregar un nuevo cliente al sistema", + }, +]; + +export function QuickActions() { + return ( + + + + + + + Acciones Rapidas + + + +
+ {quickActions.map((action) => ( + + + + ))} +
+
+
+ ); +} diff --git a/apps/web/components/dashboard/recent-bookings.tsx b/apps/web/components/dashboard/recent-bookings.tsx new file mode 100644 index 0000000..8d3e9ce --- /dev/null +++ b/apps/web/components/dashboard/recent-bookings.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { cn, formatTime } from "@/lib/utils"; +import Link from "next/link"; + +interface Booking { + id: string; + startTime: string; + endTime: string; + status: string; + court: { + id: string; + name: string; + }; + client: { + id: string; + name: string; + } | null; +} + +interface RecentBookingsProps { + bookings: Booking[]; + isLoading?: boolean; +} + +const statusConfig: Record = { + PENDING: { + label: "Pendiente", + className: "bg-yellow-100 text-yellow-700", + }, + CONFIRMED: { + label: "Confirmada", + className: "bg-blue-100 text-blue-700", + }, + COMPLETED: { + label: "Completada", + className: "bg-green-100 text-green-700", + }, + CANCELLED: { + label: "Cancelada", + className: "bg-red-100 text-red-700", + }, + NO_SHOW: { + label: "No asistio", + className: "bg-gray-100 text-gray-700", + }, +}; + +export function RecentBookings({ bookings, isLoading = false }: RecentBookingsProps) { + if (isLoading) { + return ; + } + + return ( + + +
+ + + + + Reservas de Hoy + + + + +
+
+ + {bookings.length === 0 ? ( +
+ + + +

No hay reservas para hoy

+
+ ) : ( +
+ {bookings.map((booking) => { + const status = statusConfig[booking.status] || statusConfig.PENDING; + const startTime = new Date(booking.startTime); + const endTime = new Date(booking.endTime); + + return ( +
+ {/* Time */} +
+

+ {formatTime(startTime)} +

+

+ - {formatTime(endTime)} +

+
+ + {/* Divider */} +
+ + {/* Details */} +
+

+ {booking.client?.name || "Sin cliente"} +

+

+ {booking.court.name} +

+
+ + {/* Status badge */} + + {status.label} + +
+ ); + })} +
+ )} +
+
+ ); +} + +// Loading skeleton +export function RecentBookingsSkeleton() { + return ( + + +
+
+
+
+
+ +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/components/dashboard/stat-card.tsx b/apps/web/components/dashboard/stat-card.tsx new file mode 100644 index 0000000..a513fd3 --- /dev/null +++ b/apps/web/components/dashboard/stat-card.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { ReactNode } from "react"; + +interface StatCardProps { + title: string; + value: string | number; + icon: ReactNode; + trend?: { + value: number; + isPositive: boolean; + }; + color?: "primary" | "accent" | "green" | "blue" | "purple" | "orange"; +} + +const colorVariants = { + primary: { + bg: "bg-primary-50", + icon: "bg-primary-100 text-primary-600", + text: "text-primary-700", + }, + accent: { + bg: "bg-accent-50", + icon: "bg-accent-100 text-accent-600", + text: "text-accent-700", + }, + green: { + bg: "bg-green-50", + icon: "bg-green-100 text-green-600", + text: "text-green-700", + }, + blue: { + bg: "bg-blue-50", + icon: "bg-blue-100 text-blue-600", + text: "text-blue-700", + }, + purple: { + bg: "bg-purple-50", + icon: "bg-purple-100 text-purple-600", + text: "text-purple-700", + }, + orange: { + bg: "bg-orange-50", + icon: "bg-orange-100 text-orange-600", + text: "text-orange-700", + }, +}; + +export function StatCard({ title, value, icon, trend, color = "primary" }: StatCardProps) { + const colors = colorVariants[color]; + + return ( + + +
+
+

{title}

+

{value}

+ {trend && ( +
+ + {trend.isPositive ? ( + + + + ) : ( + + + + )} + {trend.isPositive ? "+" : ""} + {trend.value}% + + vs ayer +
+ )} +
+
+ {icon} +
+
+
+
+ ); +} + +// Loading skeleton for stat card +export function StatCardSkeleton() { + return ( + + +
+
+
+
+
+
+
+
+
+ ); +}