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 <noreply@anthropic.com>
This commit is contained in:
427
apps/web/app/(admin)/reports/page.tsx
Normal file
427
apps/web/app/(admin)/reports/page.tsx
Normal file
@@ -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<ReportStats>({
|
||||
totalRevenue: 0,
|
||||
totalBookings: 0,
|
||||
totalClients: 0,
|
||||
avgOccupancy: 0,
|
||||
revenueChange: 0,
|
||||
bookingsChange: 0,
|
||||
clientsChange: 0,
|
||||
occupancyChange: 0,
|
||||
});
|
||||
|
||||
const [dailyRevenue, setDailyRevenue] = useState<DailyRevenue[]>([]);
|
||||
const [topProducts, setTopProducts] = useState<TopProduct[]>([]);
|
||||
const [courtStats, setCourtStats] = useState<CourtStats[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Reportes</h1>
|
||||
<p className="text-primary-600">Análisis y estadísticas del negocio</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
className="rounded-lg border border-primary-200 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="week">Última semana</option>
|
||||
<option value="month">Último mes</option>
|
||||
<option value="quarter">Último trimestre</option>
|
||||
<option value="year">Último año</option>
|
||||
</select>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Ingresos Totales"
|
||||
value={formatCurrency(stats.totalRevenue)}
|
||||
change={stats.revenueChange}
|
||||
icon={DollarSign}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Reservas"
|
||||
value={stats.totalBookings.toString()}
|
||||
change={stats.bookingsChange}
|
||||
icon={Calendar}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Clientes Activos"
|
||||
value={stats.totalClients.toString()}
|
||||
change={stats.clientsChange}
|
||||
icon={Users}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Ocupación Promedio"
|
||||
value={`${stats.avgOccupancy}%`}
|
||||
change={stats.occupancyChange}
|
||||
icon={Clock}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Revenue Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-primary" />
|
||||
Ingresos por Día
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="h-64 animate-pulse bg-primary-50 rounded" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{dailyRevenue.map((day) => (
|
||||
<div key={day.date} className="flex items-center gap-4">
|
||||
<span className="w-10 text-sm text-primary-600">{day.date}</span>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="flex-1 h-8 bg-primary-50 rounded-lg overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${(day.bookings / maxRevenue) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{ width: `${(day.sales / maxRevenue) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-20 text-right text-sm font-medium text-primary-800">
|
||||
{formatCurrency(day.total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-4 pt-2 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-primary" />
|
||||
<span className="text-xs text-primary-600">Reservas</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded bg-accent" />
|
||||
<span className="text-xs text-primary-600">Ventas</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Products */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Productos Más Vendidos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="animate-pulse flex items-center gap-4">
|
||||
<div className="h-4 bg-primary-100 rounded w-1/4" />
|
||||
<div className="flex-1 h-4 bg-primary-100 rounded" />
|
||||
<div className="h-4 bg-primary-100 rounded w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{topProducts.map((product, index) => (
|
||||
<div key={product.name} className="flex items-center gap-4">
|
||||
<span className="w-6 h-6 rounded-full bg-primary-100 text-primary-700 text-xs font-medium flex items-center justify-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-primary-800">{product.name}</p>
|
||||
<p className="text-xs text-primary-500">{product.quantity} unidades</p>
|
||||
</div>
|
||||
<span className="font-semibold text-primary-800">
|
||||
{formatCurrency(product.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Courts Performance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rendimiento por Cancha</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 bg-primary-50 rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-primary-100">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-primary-700">Cancha</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-primary-700">Sede</th>
|
||||
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Reservas</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-primary-700">Ingresos</th>
|
||||
<th className="text-center py-3 px-4 text-sm font-medium text-primary-700">Ocupación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{courtStats.map((court, index) => (
|
||||
<tr key={index} className="border-b border-primary-50 hover:bg-primary-50/50">
|
||||
<td className="py-3 px-4 font-medium text-primary-800">{court.name}</td>
|
||||
<td className="py-3 px-4 text-primary-600">{court.site}</td>
|
||||
<td className="py-3 px-4 text-center text-primary-600">{court.bookings}</td>
|
||||
<td className="py-3 px-4 text-right font-medium text-primary-800">
|
||||
{formatCurrency(court.revenue)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-24 h-2 bg-primary-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
court.occupancy >= 70
|
||||
? "bg-accent"
|
||||
: court.occupancy >= 50
|
||||
? "bg-amber-500"
|
||||
: "bg-red-400"
|
||||
}`}
|
||||
style={{ width: `${court.occupancy}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-primary-600 w-10">{court.occupancy}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Mejor Día</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-primary-800">Sábado</p>
|
||||
<p className="text-sm text-primary-600">
|
||||
{formatCurrency(12600)} en ingresos promedio
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Hora Pico</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-primary-800">18:00 - 20:00</p>
|
||||
<p className="text-sm text-primary-600">
|
||||
85% de ocupación en este horario
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Ticket Promedio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-primary-800">{formatCurrency(368)}</p>
|
||||
<p className="text-sm text-primary-600">
|
||||
Por visita (reserva + consumo)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Card className="animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
<div className="h-4 bg-primary-100 rounded w-1/2 mb-2" />
|
||||
<div className="h-8 bg-primary-100 rounded w-3/4 mb-2" />
|
||||
<div className="h-4 bg-primary-100 rounded w-1/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-primary-600">{title}</span>
|
||||
<Icon className="h-5 w-5 text-primary-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary-800">{value}</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 text-accent" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className={`text-sm ${isPositive ? "text-accent" : "text-red-500"}`}>
|
||||
{isPositive ? "+" : ""}
|
||||
{change}%
|
||||
</span>
|
||||
<span className="text-xs text-primary-500">vs período anterior</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user