Files
plantillas-proyectos/frontend/src/lib/components/dashboard/trend-chart.svelte
AlexeerCT 2aab5c8fdc feat: add optional seed data functionality to init_first_time.sh
- Implemented argument parsing to allow for optional loading of example data with --seed-data flag.
- Added functions to seed example data for customs brokers, clients and providers, packages, classes, parts, pedimentos, and invoices.
- Enhanced user feedback during the seeding process with detailed output of the seeded data counts.
- Updated usage instructions in the script header to reflect the new functionality.
2026-03-08 01:15:08 -06:00

219 lines
6.8 KiB
Svelte

<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { TrendingUp, TrendingDown, Minus, LineChart } from 'lucide-svelte';
import type { ChartDataPoint } from '$lib/api/dashboard/types';
import { onDestroy, tick } from 'svelte';
interface Props {
monthlyData: ChartDataPoint[];
class?: string;
}
let { monthlyData, class: cls = '' }: Props = $props();
let total = $derived(monthlyData.reduce((sum, d) => sum + d.value, 0));
let average = $derived(monthlyData.length > 0 ? Math.round(total / monthlyData.length) : 0);
let maxValue = $derived(Math.max(...monthlyData.map((d) => d.value), 1));
// Trend: compare last two months
let trendPct = $derived(() => {
if (monthlyData.length < 2) return 0;
const last = monthlyData[monthlyData.length - 1].value;
const prev = monthlyData[monthlyData.length - 2].value;
if (prev === 0) return 0;
return Math.round(((last - prev) / prev) * 100);
});
let canvas: HTMLCanvasElement | undefined = $state();
let chartInstance: any;
async function buildChart() {
if (!canvas || monthlyData.length < 2) return;
const {
Chart,
LineController,
LineElement, PointElement,
CategoryScale, LinearScale,
Tooltip, Filler
} = await import('chart.js');
Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Filler);
chartInstance?.destroy();
// Blue palette
const blue = 'rgb(59, 130, 246)'; // blue-500
const blueDark = 'rgb(37, 99, 235)'; // blue-600
const blueLight = 'rgba(59, 130, 246, 0.12)';
const blueMid = 'rgba(59, 130, 246, 0.5)';
const muted = '#94a3b8'; // slate-400
const gridColor = 'rgba(148, 163, 184, 0.12)';
const foreground = '#0f172a'; // slate-900
const popover = '#ffffff';
const ctx = canvas.getContext('2d')!;
const h = canvas.clientHeight || 260;
// Gradient fill under the area
const areaGrad = ctx.createLinearGradient(0, 0, 0, h);
areaGrad.addColorStop(0, blueMid);
areaGrad.addColorStop(0.6, blueLight);
areaGrad.addColorStop(1, 'rgba(59, 130, 246, 0)');
chartInstance = new Chart(canvas, {
type: 'line',
data: {
labels: monthlyData.map((d) => d.label),
datasets: [
{
label: 'Operaciones',
data: monthlyData.map((d) => d.value),
borderColor: blue,
borderWidth: 2.5,
pointBackgroundColor: blueDark,
pointBorderColor: popover,
pointBorderWidth: 2.5,
pointRadius: 5,
pointHoverRadius: 7,
pointHoverBorderWidth: 2.5,
pointHoverBackgroundColor: blueDark,
pointHoverBorderColor: popover,
tension: 0.45,
fill: true,
backgroundColor: areaGrad
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 800, easing: 'easeOutCubic' },
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: popover,
titleColor: foreground,
titleFont: { weight: 'bold', size: 12 },
bodyColor: muted,
borderColor: 'rgba(59,130,246,0.25)',
borderWidth: 1,
padding: { x: 14, y: 10 },
cornerRadius: 10,
boxPadding: 4,
callbacks: {
label: (ctx: any) => ` ${Number(ctx.raw).toLocaleString()} operaciones`
}
}
},
scales: {
x: {
grid: { display: false },
border: { display: false },
ticks: { color: muted, font: { size: 11 }, maxRotation: 0 }
},
y: {
beginAtZero: true,
border: { display: false, dash: [4, 4] },
grid: { color: gridColor },
ticks: {
color: muted,
font: { size: 11 },
maxTicksLimit: 5,
callback: (v: any) => Number(v).toLocaleString()
}
}
}
} as any
} as any);
}
onDestroy(() => { chartInstance?.destroy(); });
$effect(() => {
const data = monthlyData;
const el = canvas;
if (el && data.length >= 2) {
tick().then(() => buildChart());
}
});
</script>
<Card.Root class={cls}>
<Card.Header class="pb-0">
<div class="flex items-start justify-between">
<div>
<Card.Title class="flex items-center gap-2 text-base">
<LineChart class="h-4 w-4 text-blue-500" />
Tendencia de Operaciones
</Card.Title>
<Card.Description class="mt-1">Evolución mensual de operaciones</Card.Description>
</div>
{#if monthlyData.length >= 2}
{@const pct = trendPct()}
<div class="flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium
{pct > 0 ? 'bg-green-50 text-green-600' : pct < 0 ? 'bg-red-50 text-red-600' : 'bg-muted text-muted-foreground'}">
{#if pct > 0}
<TrendingUp class="h-3 w-3" />+{pct}%
{:else if pct < 0}
<TrendingDown class="h-3 w-3" />{pct}%
{:else}
<Minus class="h-3 w-3" />0%
{/if}
<span class="opacity-60 ml-0.5">vs mes ant.</span>
</div>
{/if}
</div>
</Card.Header>
<Card.Content class="pt-4 pb-5">
{#if monthlyData.length === 0}
<div class="flex flex-col items-center justify-center py-14 text-muted-foreground gap-3">
<div class="rounded-full bg-blue-50 p-4">
<LineChart class="h-8 w-8 text-blue-300" />
</div>
<div class="text-center">
<p class="text-sm font-medium">Sin datos disponibles</p>
<p class="text-xs text-muted-foreground/70 mt-0.5">Los datos aparecerán aquí una vez registrados</p>
</div>
</div>
{:else if monthlyData.length === 1}
<div class="flex flex-col items-center justify-center py-8 gap-5">
<div class="text-center">
<div class="text-5xl font-bold text-blue-600 tabular-nums">{monthlyData[0].value.toLocaleString()}</div>
<div class="text-sm text-muted-foreground mt-2">
operaciones en <span class="font-semibold text-foreground">{monthlyData[0].label}</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground bg-blue-50 text-blue-600 rounded-full px-4 py-2">
<LineChart class="h-3.5 w-3.5 shrink-0" />
La gráfica aparecerá con más de un mes de datos
</div>
</div>
{:else}
<!-- Chart -->
<div class="h-56">
<canvas bind:this={canvas}></canvas>
</div>
<!-- Stats row -->
<div class="grid grid-cols-3 divide-x border-t mt-4 pt-4">
<div class="px-4 first:pl-0 last:pr-0">
<div class="text-lg font-semibold tabular-nums text-blue-600">{total.toLocaleString()}</div>
<div class="text-xs text-muted-foreground mt-0.5">Total</div>
</div>
<div class="px-4">
<div class="text-lg font-semibold tabular-nums">{average.toLocaleString()}</div>
<div class="text-xs text-muted-foreground mt-0.5">Promedio / mes</div>
</div>
<div class="px-4 text-right">
<div class="text-lg font-semibold tabular-nums">{maxValue.toLocaleString()}</div>
<div class="text-xs text-muted-foreground mt-0.5">Máximo</div>
</div>
</div>
{/if}
</Card.Content>
</Card.Root>