- 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.
219 lines
6.8 KiB
Svelte
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>
|