feat: Implement statistics page with various data visualization charts and a new data seeding script.
This commit is contained in:
parent
997ff4d64d
commit
8e7f6ca198
|
|
@ -0,0 +1,5 @@
|
||||||
|
import StatisticsPage from "@/components/statistics/statistics-page";
|
||||||
|
|
||||||
|
export default function Estadisticas() {
|
||||||
|
return <StatisticsPage />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,591 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
RadialBarChart,
|
||||||
|
RadialBar,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
} from "recharts";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Colores del tema
|
||||||
|
const COLORS = {
|
||||||
|
primary: "#3b82f6", // blue-500
|
||||||
|
secondary: "#8b5cf6", // purple-500
|
||||||
|
success: "#22c55e", // green-500
|
||||||
|
warning: "#f59e0b", // amber-500
|
||||||
|
danger: "#ef4444", // red-500
|
||||||
|
info: "#06b6d4", // cyan-500
|
||||||
|
gray: "#6b7280", // gray-500
|
||||||
|
orange: "#f97316", // orange-500
|
||||||
|
pink: "#ec4899", // pink-500
|
||||||
|
indigo: "#6366f1", // indigo-500
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
borrador: "#9ca3af", // gray-400
|
||||||
|
abierto: "#3b82f6", // blue-500
|
||||||
|
cerrado: "#22c55e", // green-500
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROGRESS_COLORS = ["#ef4444", "#f97316", "#f59e0b", "#22c55e"];
|
||||||
|
|
||||||
|
// Tooltip personalizado
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ name: string; value: number; color?: string }>;
|
||||||
|
label?: string;
|
||||||
|
formatter?: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomTooltip({ active, payload, label, formatter }: CustomTooltipProps) {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 shadow-lg rounded-lg p-3">
|
||||||
|
{label && <p className="text-sm font-medium text-gray-700 mb-1">{label}</p>}
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||||
|
{entry.name}: {formatter ? formatter(entry.value) : entry.value}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PIE CHART ====================
|
||||||
|
interface PieChartData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusPieChartProps {
|
||||||
|
data: PieChartData[];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPieChart({ data, title, subtitle }: StatusPieChartProps) {
|
||||||
|
const total = data.reduce((acc, item) => acc + item.value, 0);
|
||||||
|
const colors = [STATUS_COLORS.borrador, STATUS_COLORS.abierto, STATUS_COLORS.cerrado];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={90}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color || colors[index % colors.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center gap-4 mt-2">
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<div key={entry.name} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color || colors[index % colors.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
{entry.name}: {entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DONUT CHART ====================
|
||||||
|
interface DonutChartProps {
|
||||||
|
data: PieChartData[];
|
||||||
|
title: string;
|
||||||
|
centerLabel?: string;
|
||||||
|
centerValue?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DonutChart({ data, title, centerLabel, centerValue }: DonutChartProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64 relative">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={70}
|
||||||
|
outerRadius={100}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color || PROGRESS_COLORS[index % PROGRESS_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
{centerLabel && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-2xl font-bold text-gray-900">{centerValue}</span>
|
||||||
|
<span className="text-xs text-gray-500">{centerLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<div key={entry.name} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color || PROGRESS_COLORS[index % PROGRESS_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-600">{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== RADIAL BAR CHART ====================
|
||||||
|
interface RadialBarData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
fill: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RadialProgressChartProps {
|
||||||
|
data: RadialBarData[];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadialProgressChart({ data, title, subtitle }: RadialProgressChartProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<RadialBarChart
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius="30%"
|
||||||
|
outerRadius="100%"
|
||||||
|
data={data}
|
||||||
|
startAngle={180}
|
||||||
|
endAngle={0}
|
||||||
|
>
|
||||||
|
<RadialBar
|
||||||
|
background
|
||||||
|
dataKey="value"
|
||||||
|
cornerRadius={10}
|
||||||
|
label={{ position: "insideStart", fill: "#fff", fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip formatter={(v) => `${v}%`} />} />
|
||||||
|
<Legend
|
||||||
|
iconSize={10}
|
||||||
|
layout="horizontal"
|
||||||
|
verticalAlign="bottom"
|
||||||
|
wrapperStyle={{ fontSize: "12px" }}
|
||||||
|
/>
|
||||||
|
</RadialBarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SINGLE RADIAL GAUGE ====================
|
||||||
|
interface RadialGaugeProps {
|
||||||
|
value: number;
|
||||||
|
maxValue?: number;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadialGauge({ value, maxValue = 100, title, subtitle, color = COLORS.primary }: RadialGaugeProps) {
|
||||||
|
const percentage = Math.min((value / maxValue) * 100, 100);
|
||||||
|
const data = [
|
||||||
|
{ name: title, value: percentage, fill: color },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-48 relative">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<RadialBarChart
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius="60%"
|
||||||
|
outerRadius="90%"
|
||||||
|
data={data}
|
||||||
|
startAngle={180}
|
||||||
|
endAngle={0}
|
||||||
|
>
|
||||||
|
<RadialBar
|
||||||
|
background={{ fill: "#f3f4f6" }}
|
||||||
|
dataKey="value"
|
||||||
|
cornerRadius={15}
|
||||||
|
/>
|
||||||
|
</RadialBarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none" style={{ marginTop: "-20px" }}>
|
||||||
|
<span className="text-3xl font-bold text-gray-900">{value}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== BAR CHART ====================
|
||||||
|
interface BarChartData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
value2?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HorizontalBarChartProps {
|
||||||
|
data: BarChartData[];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
dataKey?: string;
|
||||||
|
color?: string;
|
||||||
|
formatter?: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HorizontalBarChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
dataKey = "value",
|
||||||
|
color = COLORS.primary,
|
||||||
|
formatter,
|
||||||
|
}: HorizontalBarChartProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ left: 20, right: 20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis type="number" tickFormatter={formatter} />
|
||||||
|
<YAxis type="category" dataKey="name" width={100} tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip content={<CustomTooltip formatter={formatter} />} />
|
||||||
|
<Bar dataKey={dataKey} fill={color} radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== VERTICAL BAR CHART ====================
|
||||||
|
interface VerticalBarChartProps {
|
||||||
|
data: BarChartData[];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
color?: string;
|
||||||
|
secondaryColor?: string;
|
||||||
|
showSecondary?: boolean;
|
||||||
|
formatter?: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerticalBarChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
color = COLORS.primary,
|
||||||
|
secondaryColor = COLORS.secondary,
|
||||||
|
showSecondary = false,
|
||||||
|
formatter,
|
||||||
|
}: VerticalBarChartProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tickFormatter={formatter} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip content={<CustomTooltip formatter={formatter} />} />
|
||||||
|
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} name="Presupuesto" />
|
||||||
|
{showSecondary && (
|
||||||
|
<Bar dataKey="value2" fill={secondaryColor} radius={[4, 4, 0, 0]} name="Gastado" />
|
||||||
|
)}
|
||||||
|
{showSecondary && <Legend wrapperStyle={{ fontSize: "12px" }} />}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AREA CHART ====================
|
||||||
|
interface AreaChartData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
value2?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AreaChartProps {
|
||||||
|
data: AreaChartData[];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
color?: string;
|
||||||
|
gradientId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AreaChartComponent({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
color = COLORS.primary,
|
||||||
|
gradientId = "colorValue",
|
||||||
|
}: AreaChartProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={color}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== LINE CHART ====================
|
||||||
|
interface LineChartData {
|
||||||
|
name: string;
|
||||||
|
presupuesto: number;
|
||||||
|
gastado: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BudgetLineChartProps {
|
||||||
|
data: LineChartData[];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BudgetLineChart({ data, title, subtitle }: BudgetLineChartProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-45} textAnchor="end" height={60} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} />
|
||||||
|
<Tooltip
|
||||||
|
content={<CustomTooltip formatter={(v) => `€${v.toLocaleString("es-ES")}`} />}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: "12px" }} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="presupuesto"
|
||||||
|
stroke={COLORS.primary}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
name="Presupuesto"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="gastado"
|
||||||
|
stroke={COLORS.success}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
name="Gastado"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== STACKED BAR CHART ====================
|
||||||
|
interface StackedBarData {
|
||||||
|
name: string;
|
||||||
|
completado: number;
|
||||||
|
restante: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StackedProgressChartProps {
|
||||||
|
data: StackedBarData[];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StackedProgressChart({ data, title, subtitle }: StackedProgressChartProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ left: 0, right: 20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis type="number" domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
|
||||||
|
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip content={<CustomTooltip formatter={(v) => `${v}%`} />} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: "12px" }} />
|
||||||
|
<Bar dataKey="completado" stackId="a" fill={COLORS.success} name="Completado" radius={[0, 0, 0, 0]} />
|
||||||
|
<Bar dataKey="restante" stackId="a" fill="#e5e7eb" name="Restante" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MULTI RADIAL CHART ====================
|
||||||
|
interface MultiRadialData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
fill: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultiRadialChartProps {
|
||||||
|
data: MultiRadialData[];
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiRadialChart({ data, title, subtitle }: MultiRadialChartProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500">{subtitle}</p>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<RadialBarChart
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius="20%"
|
||||||
|
outerRadius="90%"
|
||||||
|
data={data}
|
||||||
|
startAngle={90}
|
||||||
|
endAngle={-270}
|
||||||
|
>
|
||||||
|
<RadialBar
|
||||||
|
background={{ fill: "#f3f4f6" }}
|
||||||
|
dataKey="value"
|
||||||
|
cornerRadius={5}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip formatter={(v) => `${v}%`} />} />
|
||||||
|
<Legend
|
||||||
|
iconSize={10}
|
||||||
|
layout="vertical"
|
||||||
|
verticalAlign="middle"
|
||||||
|
align="right"
|
||||||
|
wrapperStyle={{ fontSize: "11px", right: 0 }}
|
||||||
|
/>
|
||||||
|
</RadialBarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { COLORS, STATUS_COLORS, PROGRESS_COLORS };
|
||||||
|
|
@ -0,0 +1,885 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
LayoutGrid,
|
||||||
|
TrendingUp,
|
||||||
|
CheckCircle2,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
Target,
|
||||||
|
PiggyBank,
|
||||||
|
FileText,
|
||||||
|
AlertCircle,
|
||||||
|
BarChart3,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { getProjects } from "@/lib/projectsService";
|
||||||
|
import { Project, ProjectStatus, STATUS_CONFIG } from "@/types/project";
|
||||||
|
|
||||||
|
// Componente para una card de estadística individual
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
positive?: boolean;
|
||||||
|
};
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, subtitle, icon, trend, highlight }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className={`border-gray-200 hover:shadow-md transition-shadow ${highlight ? "ring-2 ring-blue-500/20" : ""}`}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600">{title}</CardTitle>
|
||||||
|
{icon}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{value}</div>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500 mt-1">{subtitle}</p>}
|
||||||
|
{trend && (
|
||||||
|
<div className={`flex items-center gap-1 mt-2 text-xs ${trend.positive ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
<span>{trend.positive ? "+" : ""}{trend.value}%</span>
|
||||||
|
<span className="text-gray-500">{trend.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente para mostrar la distribución por estado
|
||||||
|
interface StatusDistributionProps {
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDistribution({ projects }: StatusDistributionProps) {
|
||||||
|
const statusCounts: Record<ProjectStatus, number> = {
|
||||||
|
"0": projects.filter((p) => p.status === "0").length,
|
||||||
|
"1": projects.filter((p) => p.status === "1").length,
|
||||||
|
"2": projects.filter((p) => p.status === "2").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = projects.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
Distribucion por Estado
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{(Object.keys(statusCounts) as ProjectStatus[]).map((status) => {
|
||||||
|
const count = statusCounts[status];
|
||||||
|
const percentage = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
|
const config = STATUS_CONFIG[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={status} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="outline" className={config.getColorClasses()}>
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{count} ({percentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-100 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all ${
|
||||||
|
status === "0" ? "bg-gray-400" : status === "1" ? "bg-blue-500" : "bg-green-500"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente para estadísticas de presupuesto
|
||||||
|
interface BudgetStatsProps {
|
||||||
|
projects: Project[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BudgetStats({ projects, title = "Resumen de Presupuesto" }: BudgetStatsProps) {
|
||||||
|
const totalBudget = projects.reduce((acc, p) => acc + p.budget, 0);
|
||||||
|
const totalSpent = projects.reduce((acc, p) => acc + p.spent, 0);
|
||||||
|
const remaining = totalBudget - totalSpent;
|
||||||
|
const spentPercentage = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<PiggyBank className="w-4 h-4" />
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{totalBudget >= 1000 ? `${(totalBudget / 1000).toFixed(0)}k` : totalBudget.toLocaleString("es-ES")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Presupuesto Total</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">
|
||||||
|
{totalSpent >= 1000 ? `${(totalSpent / 1000).toFixed(0)}k` : totalSpent.toLocaleString("es-ES")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Gastado</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`text-2xl font-bold ${remaining >= 0 ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
{remaining >= 1000 ? `${(remaining / 1000).toFixed(0)}k` : remaining.toLocaleString("es-ES")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Restante</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Consumido</span>
|
||||||
|
<span className="font-medium">{spentPercentage}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-100 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className={`h-3 rounded-full transition-all ${
|
||||||
|
spentPercentage > 90 ? "bg-red-500" : spentPercentage > 70 ? "bg-yellow-500" : "bg-blue-500"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(spentPercentage, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funciones helper para calcular estadísticas
|
||||||
|
function calculateStats(projects: Project[]) {
|
||||||
|
const total = projects.length;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
avgProgress: 0,
|
||||||
|
totalBudget: 0,
|
||||||
|
totalSpent: 0,
|
||||||
|
avgBudget: 0,
|
||||||
|
budgetConsumed: 0,
|
||||||
|
minBudget: 0,
|
||||||
|
maxBudget: 0,
|
||||||
|
uniqueClients: 0,
|
||||||
|
oldestProject: null as string | null,
|
||||||
|
newestProject: null as string | null,
|
||||||
|
avgDuration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBudget = projects.reduce((acc, p) => acc + p.budget, 0);
|
||||||
|
const totalSpent = projects.reduce((acc, p) => acc + p.spent, 0);
|
||||||
|
const avgProgress = Math.round(projects.reduce((acc, p) => acc + p.progress, 0) / total);
|
||||||
|
const avgBudget = Math.round(totalBudget / total);
|
||||||
|
const budgetConsumed = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
|
||||||
|
|
||||||
|
const budgets = projects.map((p) => p.budget);
|
||||||
|
const minBudget = Math.min(...budgets);
|
||||||
|
const maxBudget = Math.max(...budgets);
|
||||||
|
|
||||||
|
const uniqueClients = new Set(projects.map((p) => p.client)).size;
|
||||||
|
|
||||||
|
const dates = projects.map((p) => new Date(p.startDate).getTime()).filter((d) => !isNaN(d));
|
||||||
|
const oldestProject = dates.length > 0 ? new Date(Math.min(...dates)).toLocaleDateString("es-ES") : null;
|
||||||
|
const newestProject = dates.length > 0 ? new Date(Math.max(...dates)).toLocaleDateString("es-ES") : null;
|
||||||
|
|
||||||
|
// Calcular duracion promedio en dias
|
||||||
|
const durations = projects
|
||||||
|
.map((p) => {
|
||||||
|
const start = new Date(p.startDate).getTime();
|
||||||
|
const end = new Date(p.endDate).getTime();
|
||||||
|
if (isNaN(start) || isNaN(end)) return null;
|
||||||
|
return Math.ceil((end - start) / (1000 * 60 * 60 * 24));
|
||||||
|
})
|
||||||
|
.filter((d): d is number => d !== null && d > 0);
|
||||||
|
|
||||||
|
const avgDuration = durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
avgProgress,
|
||||||
|
totalBudget,
|
||||||
|
totalSpent,
|
||||||
|
avgBudget,
|
||||||
|
budgetConsumed,
|
||||||
|
minBudget,
|
||||||
|
maxBudget,
|
||||||
|
uniqueClients,
|
||||||
|
oldestProject,
|
||||||
|
newestProject,
|
||||||
|
avgDuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel de estadísticas para "Todos los proyectos"
|
||||||
|
interface AllProjectsStatsProps {
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function AllProjectsStats({ projects }: AllProjectsStatsProps) {
|
||||||
|
const stats = calculateStats(projects);
|
||||||
|
const activeProjects = projects.filter((p) => p.status !== "2" && p.progress < 100);
|
||||||
|
const completedProjects = projects.filter((p) => p.status === "2" || p.progress >= 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Cards principales */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
title="Total Proyectos"
|
||||||
|
value={stats.total}
|
||||||
|
subtitle="En toda la plataforma"
|
||||||
|
icon={<LayoutGrid className="w-4 h-4 text-gray-400" />}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Proyectos Activos"
|
||||||
|
value={activeProjects.length}
|
||||||
|
subtitle={`${stats.total > 0 ? Math.round((activeProjects.length / stats.total) * 100) : 0}% del total`}
|
||||||
|
icon={<TrendingUp className="w-4 h-4 text-blue-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Proyectos Completados"
|
||||||
|
value={completedProjects.length}
|
||||||
|
subtitle={`${stats.total > 0 ? Math.round((completedProjects.length / stats.total) * 100) : 0}% del total`}
|
||||||
|
icon={<CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Progreso Medio"
|
||||||
|
value={`${stats.avgProgress}%`}
|
||||||
|
subtitle="De todos los proyectos"
|
||||||
|
icon={<Clock className="w-4 h-4 text-purple-500" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segunda fila: Presupuesto */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
title="Presupuesto Total"
|
||||||
|
value={`${(stats.totalBudget / 1000).toFixed(0)}k`}
|
||||||
|
subtitle="Suma de todos los proyectos"
|
||||||
|
icon={<DollarSign className="w-4 h-4 text-green-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Gastado Total"
|
||||||
|
value={`${(stats.totalSpent / 1000).toFixed(0)}k`}
|
||||||
|
subtitle={`${stats.budgetConsumed}% del presupuesto`}
|
||||||
|
icon={<PiggyBank className="w-4 h-4 text-orange-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Presupuesto Promedio"
|
||||||
|
value={`${(stats.avgBudget / 1000).toFixed(1)}k`}
|
||||||
|
subtitle="Por proyecto"
|
||||||
|
icon={<Target className="w-4 h-4 text-blue-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Clientes Unicos"
|
||||||
|
value={stats.uniqueClients}
|
||||||
|
subtitle="Con proyectos activos"
|
||||||
|
icon={<Users className="w-4 h-4 text-indigo-500" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tercera fila: Fechas y distribucion */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<StatCard
|
||||||
|
title="Duracion Promedio"
|
||||||
|
value={`${stats.avgDuration} dias`}
|
||||||
|
subtitle="Por proyecto"
|
||||||
|
icon={<Calendar className="w-4 h-4 text-teal-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Proyecto mas Antiguo"
|
||||||
|
value={stats.oldestProject || "N/A"}
|
||||||
|
subtitle="Fecha de inicio"
|
||||||
|
icon={<FileText className="w-4 h-4 text-gray-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Proyecto mas Reciente"
|
||||||
|
value={stats.newestProject || "N/A"}
|
||||||
|
subtitle="Fecha de inicio"
|
||||||
|
icon={<FileText className="w-4 h-4 text-blue-500" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cuarta fila: Graficos/Distribuciones */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<StatusDistribution projects={projects} />
|
||||||
|
<BudgetStats projects={projects} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info adicional: Rango de presupuestos */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
Rango de Presupuestos
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">Minimo</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{stats.minBudget.toLocaleString("es-ES")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 mx-4 h-px bg-gradient-to-r from-red-300 via-yellow-300 to-green-300" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">Maximo</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{stats.maxBudget.toLocaleString("es-ES")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
Proyectos por Progreso
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-4 gap-2 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-red-600">
|
||||||
|
{projects.filter((p) => p.progress < 25).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">0-25%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-orange-600">
|
||||||
|
{projects.filter((p) => p.progress >= 25 && p.progress < 50).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">25-50%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-yellow-600">
|
||||||
|
{projects.filter((p) => p.progress >= 50 && p.progress < 75).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">50-75%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-green-600">
|
||||||
|
{projects.filter((p) => p.progress >= 75).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">75-100%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel de estadísticas para "Proyectos Activos"
|
||||||
|
interface ActiveProjectsStatsProps {
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveProjectsStats({ projects }: ActiveProjectsStatsProps) {
|
||||||
|
// Filtrar solo proyectos activos (no cerrados y no completados al 100%)
|
||||||
|
const activeProjects = projects.filter((p) => p.status !== "2" && p.progress < 100);
|
||||||
|
const stats = calculateStats(activeProjects);
|
||||||
|
|
||||||
|
if (activeProjects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<TrendingUp className="w-16 h-16 text-gray-300 mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos activos</h3>
|
||||||
|
<p className="text-gray-500 mt-2">Todos los proyectos estan completados o cerrados</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proyectos que necesitan atencion (bajo progreso o alto consumo de presupuesto)
|
||||||
|
const needsAttention = activeProjects.filter((p) => {
|
||||||
|
const spentPercentage = p.budget > 0 ? (p.spent / p.budget) * 100 : 0;
|
||||||
|
return p.progress < 25 || spentPercentage > 80;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proyectos proximos a terminar
|
||||||
|
const nearCompletion = activeProjects.filter((p) => p.progress >= 75);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Cards principales */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
title="Proyectos Activos"
|
||||||
|
value={stats.total}
|
||||||
|
subtitle="En progreso actualmente"
|
||||||
|
icon={<TrendingUp className="w-4 h-4 text-blue-500" />}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Progreso Promedio"
|
||||||
|
value={`${stats.avgProgress}%`}
|
||||||
|
subtitle="De proyectos activos"
|
||||||
|
icon={<Clock className="w-4 h-4 text-purple-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Presupuesto Activo"
|
||||||
|
value={`${(stats.totalBudget / 1000).toFixed(0)}k`}
|
||||||
|
subtitle="En proyectos en curso"
|
||||||
|
icon={<DollarSign className="w-4 h-4 text-green-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Presupuesto Consumido"
|
||||||
|
value={`${stats.budgetConsumed}%`}
|
||||||
|
subtitle={`${(stats.totalSpent / 1000).toFixed(0)}k gastado`}
|
||||||
|
icon={<PiggyBank className="w-4 h-4 text-orange-500" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alertas */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card className="border-orange-200 bg-orange-50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-orange-700 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
Requieren Atencion
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold text-orange-600">{needsAttention.length}</p>
|
||||||
|
<p className="text-xs text-orange-600 mt-1">
|
||||||
|
Proyectos con bajo progreso o alto consumo de presupuesto
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-green-200 bg-green-50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-green-700 flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
Proximos a Completar
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold text-green-600">{nearCompletion.length}</p>
|
||||||
|
<p className="text-xs text-green-600 mt-1">
|
||||||
|
Proyectos con 75% o mas de progreso
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Estadísticas adicionales */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<StatCard
|
||||||
|
title="Clientes Activos"
|
||||||
|
value={stats.uniqueClients}
|
||||||
|
subtitle="Con proyectos en curso"
|
||||||
|
icon={<Users className="w-4 h-4 text-indigo-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Duracion Promedio"
|
||||||
|
value={`${stats.avgDuration} dias`}
|
||||||
|
subtitle="De proyectos activos"
|
||||||
|
icon={<Calendar className="w-4 h-4 text-teal-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Presupuesto Promedio"
|
||||||
|
value={`${(stats.avgBudget / 1000).toFixed(1)}k`}
|
||||||
|
subtitle="Por proyecto activo"
|
||||||
|
icon={<Target className="w-4 h-4 text-blue-500" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distribucion y Budget */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<StatusDistribution projects={activeProjects} />
|
||||||
|
<BudgetStats projects={activeProjects} title="Presupuesto de Proyectos Activos" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Proyectos por progreso */}
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
Distribucion por Progreso
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-4 gap-4 text-center">
|
||||||
|
<div className="p-4 bg-red-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-red-600">
|
||||||
|
{activeProjects.filter((p) => p.progress < 25).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-red-600 mt-1">Inicio (0-25%)</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-orange-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-orange-600">
|
||||||
|
{activeProjects.filter((p) => p.progress >= 25 && p.progress < 50).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-orange-600 mt-1">En curso (25-50%)</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-yellow-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-yellow-600">
|
||||||
|
{activeProjects.filter((p) => p.progress >= 50 && p.progress < 75).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-yellow-600 mt-1">Avanzado (50-75%)</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{activeProjects.filter((p) => p.progress >= 75).length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-green-600 mt-1">Casi listo (75-99%)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel de estadísticas para "Proyectos Completados"
|
||||||
|
interface CompletedProjectsStatsProps {
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompletedProjectsStats({ projects }: CompletedProjectsStatsProps) {
|
||||||
|
// Filtrar proyectos completados (cerrados o 100% progreso)
|
||||||
|
const completedProjects = projects.filter((p) => p.status === "2" || p.progress >= 100);
|
||||||
|
const stats = calculateStats(completedProjects);
|
||||||
|
|
||||||
|
if (completedProjects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<CheckCircle2 className="w-16 h-16 text-gray-300 mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold text-gray-700">No hay proyectos completados</h3>
|
||||||
|
<p className="text-gray-500 mt-2">Aun no se ha completado ningun proyecto</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proyectos que terminaron dentro del presupuesto
|
||||||
|
const withinBudget = completedProjects.filter((p) => p.spent <= p.budget);
|
||||||
|
const overBudget = completedProjects.filter((p) => p.spent > p.budget);
|
||||||
|
|
||||||
|
// Eficiencia promedio (presupuesto restante / presupuesto total)
|
||||||
|
const avgEfficiency =
|
||||||
|
completedProjects.length > 0
|
||||||
|
? Math.round(
|
||||||
|
completedProjects.reduce((acc, p) => {
|
||||||
|
if (p.budget === 0) return acc;
|
||||||
|
return acc + ((p.budget - p.spent) / p.budget) * 100;
|
||||||
|
}, 0) / completedProjects.length
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Cards principales */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
title="Proyectos Completados"
|
||||||
|
value={stats.total}
|
||||||
|
subtitle="Finalizados exitosamente"
|
||||||
|
icon={<CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Presupuesto Gestionado"
|
||||||
|
value={`${(stats.totalBudget / 1000).toFixed(0)}k`}
|
||||||
|
subtitle="En proyectos completados"
|
||||||
|
icon={<DollarSign className="w-4 h-4 text-green-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Gastado"
|
||||||
|
value={`${(stats.totalSpent / 1000).toFixed(0)}k`}
|
||||||
|
subtitle={`${stats.budgetConsumed}% del presupuesto`}
|
||||||
|
icon={<PiggyBank className="w-4 h-4 text-blue-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Eficiencia Promedio"
|
||||||
|
value={`${Math.max(0, avgEfficiency)}%`}
|
||||||
|
subtitle="Presupuesto ahorrado"
|
||||||
|
icon={<Target className="w-4 h-4 text-purple-500" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cumplimiento de presupuesto */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Card className="border-green-200 bg-green-50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-green-700 flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
Dentro del Presupuesto
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold text-green-600">{withinBudget.length}</p>
|
||||||
|
<p className="text-xs text-green-600 mt-1">
|
||||||
|
{stats.total > 0 ? Math.round((withinBudget.length / stats.total) * 100) : 0}% de los proyectos completados
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-red-200 bg-red-50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-red-700 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
Excedieron Presupuesto
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold text-red-600">{overBudget.length}</p>
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
{stats.total > 0 ? Math.round((overBudget.length / stats.total) * 100) : 0}% de los proyectos completados
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Estadísticas adicionales */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<StatCard
|
||||||
|
title="Clientes Atendidos"
|
||||||
|
value={stats.uniqueClients}
|
||||||
|
subtitle="Con proyectos completados"
|
||||||
|
icon={<Users className="w-4 h-4 text-indigo-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Duracion Promedio"
|
||||||
|
value={`${stats.avgDuration} dias`}
|
||||||
|
subtitle="Por proyecto completado"
|
||||||
|
icon={<Calendar className="w-4 h-4 text-teal-500" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Presupuesto Promedio"
|
||||||
|
value={`${(stats.avgBudget / 1000).toFixed(1)}k`}
|
||||||
|
subtitle="Por proyecto completado"
|
||||||
|
icon={<Target className="w-4 h-4 text-blue-500" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distribucion y Budget */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<BudgetStats projects={completedProjects} title="Resumen Final de Presupuesto" />
|
||||||
|
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
Rango de Presupuestos Completados
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">Minimo</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{stats.minBudget.toLocaleString("es-ES")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 mx-4 h-px bg-gradient-to-r from-green-300 to-green-500" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">Maximo</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900">{stats.maxBudget.toLocaleString("es-ES")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<Card className="border-gray-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Rango de Fechas
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">Primer Proyecto</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{stats.oldestProject || "N/A"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 mx-4 flex items-center justify-center">
|
||||||
|
<div className="h-px w-full bg-gradient-to-r from-blue-300 to-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500">Ultimo Proyecto</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{stats.newestProject || "N/A"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente de skeleton para carga
|
||||||
|
function StatisticsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i} className="border-gray-200">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16 mb-2" />
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i} className="border-gray-200">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16 mb-2" />
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente principal de la página
|
||||||
|
export default function StatisticsPage() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getProjects();
|
||||||
|
setProjects(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading projects:", err);
|
||||||
|
setError("Error al cargar los proyectos");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadProjects();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertCircle className="w-16 h-16 text-red-400 mx-auto mb-4" />
|
||||||
|
<p className="text-red-600 text-lg">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = projects.filter((p) => p.status !== "2" && p.progress < 100).length;
|
||||||
|
const completedCount = projects.filter((p) => p.status === "2" || p.progress >= 100).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="py-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg">
|
||||||
|
<BarChart3 className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Estadisticas</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Analisis detallado de {projects.length} proyectos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{loading ? (
|
||||||
|
<StatisticsSkeleton />
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="todos" className="space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-3 lg:w-auto lg:inline-grid">
|
||||||
|
<TabsTrigger value="todos" className="gap-2">
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Todos</span>
|
||||||
|
<Badge variant="secondary" className="ml-1">
|
||||||
|
{projects.length}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="activos" className="gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Activos</span>
|
||||||
|
<Badge variant="secondary" className="ml-1 bg-blue-100 text-blue-700">
|
||||||
|
{activeCount}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="completados" className="gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Completados</span>
|
||||||
|
<Badge variant="secondary" className="ml-1 bg-green-100 text-green-700">
|
||||||
|
{completedCount}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="todos">
|
||||||
|
<AllProjectsStats projects={projects} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="activos">
|
||||||
|
<ActiveProjectsStats projects={projects} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="completados">
|
||||||
|
<CompletedProjectsStats projects={projects} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"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<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...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={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...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={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
|
@ -2262,6 +2264,92 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
|
"version": "1.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
|
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-tooltip": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
|
|
@ -2561,6 +2649,42 @@
|
||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||||
|
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -2568,6 +2692,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
|
@ -2873,6 +3009,69 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -2924,6 +3123,12 @@
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.48.1",
|
"version": "8.48.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
||||||
|
|
@ -4156,6 +4361,127 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
|
|
@ -4235,6 +4561,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -4539,6 +4871,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -4986,6 +5328,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -5481,6 +5829,16 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -5523,6 +5881,15 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
|
|
@ -7206,9 +7573,31 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
|
@ -7299,6 +7688,51 @@
|
||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -7343,6 +7777,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
|
|
@ -8057,6 +8497,12 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|
@ -8454,6 +8900,28 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script para crear proyectos de prueba en Dolibarr llamando directamente a la API
|
||||||
|
* NO requiere que Next.js esté corriendo
|
||||||
|
*
|
||||||
|
* Uso: node scripts/seed-projects-direct.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Configuración - lee de .env.local o usa valores por defecto
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Leer .env.local
|
||||||
|
function loadEnv() {
|
||||||
|
const envPath = path.join(__dirname, '..', '.env.local');
|
||||||
|
const env = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(envPath, 'utf8');
|
||||||
|
content.split('\n').forEach(line => {
|
||||||
|
const [key, ...valueParts] = line.split('=');
|
||||||
|
if (key && valueParts.length > 0) {
|
||||||
|
env[key.trim()] = valueParts.join('=').trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error leyendo .env.local:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = loadEnv();
|
||||||
|
const DOLIBARR_API_URL = env.NEXT_PUBLIC_API_URL || 'http://localhost:8200/api/index.php';
|
||||||
|
const DOLIBARR_API_KEY = env.NEXT_PUBLIC_DOLIBARR_API_KEY || '';
|
||||||
|
|
||||||
|
if (!DOLIBARR_API_KEY) {
|
||||||
|
console.error('❌ ERROR: No se encontró NEXT_PUBLIC_DOLIBARR_API_KEY en .env.local');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nombres de clientes para asignar aleatoriamente
|
||||||
|
const clientNames = [
|
||||||
|
"TechCorp Solutions",
|
||||||
|
"Innovatech S.L.",
|
||||||
|
"Digital Factory",
|
||||||
|
"CloudBase Systems",
|
||||||
|
"DataPrime Analytics",
|
||||||
|
"WebMasters Pro",
|
||||||
|
"AppDev Studio",
|
||||||
|
"CyberTech Security",
|
||||||
|
"SmartBiz Solutions",
|
||||||
|
"NextGen Software"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Datos de proyectos de ejemplo con variedad de estados y presupuestos
|
||||||
|
const projectsData = [
|
||||||
|
{
|
||||||
|
title: "Desarrollo de App Movil",
|
||||||
|
ref: "PROJ-2025-001",
|
||||||
|
description: "Aplicacion movil para gestion de inventario con funcionalidades de escaneo QR y sincronizacion en tiempo real.",
|
||||||
|
opp_amount: 45000,
|
||||||
|
opp_percent: 65,
|
||||||
|
date_start: Math.floor(new Date('2025-01-15').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-06-30').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Rediseno Web Corporativo",
|
||||||
|
ref: "PROJ-2025-002",
|
||||||
|
description: "Renovacion completa del sitio web corporativo con nuevo diseno responsive y optimizacion SEO.",
|
||||||
|
opp_amount: 28000,
|
||||||
|
opp_percent: 90,
|
||||||
|
date_start: Math.floor(new Date('2024-11-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-02-28').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Sistema de Facturacion Automatica",
|
||||||
|
ref: "PROJ-2025-003",
|
||||||
|
description: "Desarrollo de sistema automatizado para generacion y envio de facturas con integracion a contabilidad.",
|
||||||
|
opp_amount: 62000,
|
||||||
|
opp_percent: 35,
|
||||||
|
date_start: Math.floor(new Date('2025-02-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-08-31').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Migracion a Cloud AWS",
|
||||||
|
ref: "PROJ-2025-004",
|
||||||
|
description: "Migracion completa de infraestructura on-premise a AWS con configuracion de alta disponibilidad.",
|
||||||
|
opp_amount: 85000,
|
||||||
|
opp_percent: 20,
|
||||||
|
date_start: Math.floor(new Date('2025-03-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-12-31').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Dashboard Analitico BI",
|
||||||
|
ref: "PROJ-2025-005",
|
||||||
|
description: "Creacion de dashboard de Business Intelligence para analisis de ventas y KPIs en tiempo real.",
|
||||||
|
opp_amount: 38000,
|
||||||
|
opp_percent: 100,
|
||||||
|
date_start: Math.floor(new Date('2024-09-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2024-12-15').getTime() / 1000),
|
||||||
|
status: 2, // Cerrado
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "API REST Microservicios",
|
||||||
|
ref: "PROJ-2025-006",
|
||||||
|
description: "Desarrollo de arquitectura de microservicios con API REST para integracion de sistemas legacy.",
|
||||||
|
opp_amount: 72000,
|
||||||
|
opp_percent: 50,
|
||||||
|
date_start: Math.floor(new Date('2025-01-20').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-07-31').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "App de Gestion de Proyectos",
|
||||||
|
ref: "PROJ-2025-007",
|
||||||
|
description: "Plataforma web para gestion agil de proyectos con tableros Kanban, Gantt y reporting automatico.",
|
||||||
|
opp_amount: 55000,
|
||||||
|
opp_percent: 75,
|
||||||
|
date_start: Math.floor(new Date('2024-12-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-05-31').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "E-commerce con Marketplace",
|
||||||
|
ref: "PROJ-2025-008",
|
||||||
|
description: "Plataforma de e-commerce completa con funcionalidad de marketplace multivendedor y pasarela de pagos.",
|
||||||
|
opp_amount: 120000,
|
||||||
|
opp_percent: 15,
|
||||||
|
date_start: Math.floor(new Date('2025-04-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2026-03-31').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Sistema CRM Personalizado",
|
||||||
|
ref: "PROJ-2025-009",
|
||||||
|
description: "CRM a medida con automatizacion de marketing, gestion de leads y pipeline de ventas.",
|
||||||
|
opp_amount: 48000,
|
||||||
|
opp_percent: 0,
|
||||||
|
date_start: Math.floor(new Date('2025-05-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-11-30').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Portal de Empleados",
|
||||||
|
ref: "PROJ-2025-010",
|
||||||
|
description: "Portal interno para empleados con gestion de vacaciones, nominas, formacion y comunicacion interna.",
|
||||||
|
opp_amount: 35000,
|
||||||
|
opp_percent: 100,
|
||||||
|
date_start: Math.floor(new Date('2024-10-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2024-12-31').getTime() / 1000),
|
||||||
|
status: 2, // Cerrado
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Sistema de Reservas Online",
|
||||||
|
ref: "PROJ-2025-011",
|
||||||
|
description: "Plataforma de reservas con calendario interactivo, confirmacion automatica y pasarela de pago.",
|
||||||
|
opp_amount: 42000,
|
||||||
|
opp_percent: 45,
|
||||||
|
date_start: Math.floor(new Date('2025-02-15').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-07-15').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "App de Formacion Online",
|
||||||
|
ref: "PROJ-2025-012",
|
||||||
|
description: "Plataforma LMS para formacion online con seguimiento de progreso, certificados y evaluaciones.",
|
||||||
|
opp_amount: 68000,
|
||||||
|
opp_percent: 30,
|
||||||
|
date_start: Math.floor(new Date('2025-03-10').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-10-31').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Automatizacion de Procesos RPA",
|
||||||
|
ref: "PROJ-2025-013",
|
||||||
|
description: "Implementacion de robots de automatizacion para procesos administrativos repetitivos.",
|
||||||
|
opp_amount: 95000,
|
||||||
|
opp_percent: 10,
|
||||||
|
date_start: Math.floor(new Date('2025-06-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2026-02-28').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Plataforma IoT Industrial",
|
||||||
|
ref: "PROJ-2025-014",
|
||||||
|
description: "Sistema de monitoreo IoT para maquinaria industrial con alertas predictivas y mantenimiento.",
|
||||||
|
opp_amount: 150000,
|
||||||
|
opp_percent: 5,
|
||||||
|
date_start: Math.floor(new Date('2025-07-01').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2026-06-30').getTime() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Chatbot con IA",
|
||||||
|
ref: "PROJ-2025-015",
|
||||||
|
description: "Desarrollo de chatbot inteligente para atencion al cliente con procesamiento de lenguaje natural.",
|
||||||
|
opp_amount: 32000,
|
||||||
|
opp_percent: 80,
|
||||||
|
date_start: Math.floor(new Date('2024-11-15').getTime() / 1000),
|
||||||
|
date_end: Math.floor(new Date('2025-03-15').getTime() / 1000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Llamar a la API de Dolibarr directamente
|
||||||
|
*/
|
||||||
|
async function dolibarrFetch(endpoint, options = {}) {
|
||||||
|
const url = `${DOLIBARR_API_URL}/${endpoint}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'DOLAPIKEY': DOLIBARR_API_KEY,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear un proyecto
|
||||||
|
*/
|
||||||
|
async function createProject(projectData) {
|
||||||
|
return dolibarrFetch('projects', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(projectData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar proyecto (cambiar estado a abierto)
|
||||||
|
*/
|
||||||
|
async function validateProject(projectId) {
|
||||||
|
try {
|
||||||
|
await dolibarrFetch(`projects/${projectId}/validate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ notrigger: 0 }),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cerrar proyecto
|
||||||
|
*/
|
||||||
|
async function closeProject(projectId) {
|
||||||
|
try {
|
||||||
|
await dolibarrFetch(`projects/${projectId}/close`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ notrigger: 0 }),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script principal
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
console.log('\n🚀 SEED DE PROYECTOS DE PRUEBA PARA DOLIBARR\n');
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
console.log(`📡 API Dolibarr: ${DOLIBARR_API_URL}`);
|
||||||
|
console.log(`🔑 API Key: ${DOLIBARR_API_KEY.substring(0, 10)}...`);
|
||||||
|
console.log(`📊 Proyectos a crear: ${projectsData.length}`);
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Verificar conexión con Dolibarr
|
||||||
|
console.log('🔍 Verificando conexion con Dolibarr...');
|
||||||
|
try {
|
||||||
|
await dolibarrFetch('status');
|
||||||
|
console.log('✅ Conexion exitosa con Dolibarr\n');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ERROR: No se puede conectar a Dolibarr');
|
||||||
|
console.error(` ${error.message}`);
|
||||||
|
console.error('\n Verifica que:');
|
||||||
|
console.error(' 1. Docker está corriendo');
|
||||||
|
console.error(' 2. La URL en .env.local es correcta');
|
||||||
|
console.error(' 3. La API key es válida\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < projectsData.length; i++) {
|
||||||
|
const project = projectsData[i];
|
||||||
|
const num = i + 1;
|
||||||
|
const percentage = project.opp_percent;
|
||||||
|
const shouldClose = project.status === 2;
|
||||||
|
|
||||||
|
// Asignar cliente aleatorio
|
||||||
|
const clientName = clientNames[i % clientNames.length];
|
||||||
|
|
||||||
|
// Indicador visual del progreso
|
||||||
|
const progressBar = '▓'.repeat(Math.floor(percentage / 10)) + '░'.repeat(10 - Math.floor(percentage / 10));
|
||||||
|
|
||||||
|
console.log(`[${num}/${projectsData.length}] ${project.title}`);
|
||||||
|
console.log(` 📈 Progreso: [${progressBar}] ${percentage}%`);
|
||||||
|
console.log(` 💰 Presupuesto: €${project.opp_amount.toLocaleString()}`);
|
||||||
|
console.log(` 👤 Cliente: ${clientName}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Crear proyecto
|
||||||
|
const projectPayload = {
|
||||||
|
ref: project.ref,
|
||||||
|
title: project.title,
|
||||||
|
description: project.description,
|
||||||
|
opp_amount: project.opp_amount.toString(),
|
||||||
|
opp_percent: project.opp_percent.toString(),
|
||||||
|
date_start: project.date_start,
|
||||||
|
date_end: project.date_end,
|
||||||
|
usage_opportunity: 1,
|
||||||
|
usage_task: 1,
|
||||||
|
public: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectId = await createProject(projectPayload);
|
||||||
|
console.log(` ✅ Creado con ID: ${projectId}`);
|
||||||
|
|
||||||
|
// Validar proyecto si tiene progreso > 0
|
||||||
|
if (percentage > 0) {
|
||||||
|
const validated = await validateProject(projectId);
|
||||||
|
if (validated) {
|
||||||
|
console.log(` ✓ Validado (estado: abierto)`);
|
||||||
|
|
||||||
|
// Cerrar si debe estar cerrado
|
||||||
|
if (shouldClose) {
|
||||||
|
const closed = await closeProject(projectId);
|
||||||
|
if (closed) {
|
||||||
|
console.log(` ✓ Cerrado (completado)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠ Creado pero no validado`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` 📋 Dejado como borrador`);
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
|
||||||
|
// Pequeña pausa para no saturar la API
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ❌ Error: ${error.message}`);
|
||||||
|
errors.push({ project: project.title, error: error.message });
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resumen final
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
console.log('✨ PROCESO COMPLETADO\n');
|
||||||
|
console.log(`✅ Proyectos creados exitosamente: ${successCount}`);
|
||||||
|
console.log(`❌ Proyectos con error: ${errorCount}`);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.log('\n📋 Errores encontrados:');
|
||||||
|
errors.forEach(({ project, error }) => {
|
||||||
|
console.log(` • ${project}: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
console.log('\n💡 Ahora puedes iniciar tu app con "npm run dev"');
|
||||||
|
console.log(' y ver los proyectos en http://localhost:3000\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ejecutar
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('\n❌ ERROR FATAL:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue