diff --git a/frontend/package.json b/frontend/package.json
index 65c5539..bbf870b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -30,7 +30,7 @@
"react": "19.2.5",
"react-dom": "19.2.5",
"react-router-dom": "^7.6.0",
- "recharts": "^3.8.1",
+ "recharts": "^3.8.0",
"rxjs": "^7.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 439e672..94d29ad 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -63,8 +63,8 @@ importers:
specifier: ^7.6.0
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
recharts:
- specifier: ^3.8.1
- version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1)
+ specifier: ^3.8.0
+ version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1)
rxjs:
specifier: ^7.8.1
version: 7.8.2
@@ -3730,8 +3730,8 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
- recharts@3.8.1:
- resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
+ recharts@3.8.0:
+ resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -8482,7 +8482,7 @@ snapshots:
real-require@0.2.0: {}
- recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1):
+ recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)
clsx: 2.1.1
diff --git a/frontend/src/components/page-header.tsx b/frontend/src/components/page-header.tsx
new file mode 100644
index 0000000..08e8511
--- /dev/null
+++ b/frontend/src/components/page-header.tsx
@@ -0,0 +1,41 @@
+import type { LucideIcon } from "lucide-react";
+
+/** Standard page header: icon tile + title + optional subtitle on the left,
+ * page-level actions (period navigators, CSV, refresh…) on the right.
+ * Every page uses this — the breadcrumb in the shell header stays the only
+ * other place a page identifies itself. */
+export function PageHeader({
+ icon: Icon,
+ title,
+ subtitle,
+ actions,
+}: {
+ icon: LucideIcon;
+ title: string;
+ subtitle?: string;
+ actions?: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+ {title}
+
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+ {actions && (
+
{actions}
+ )}
+
+ );
+}
diff --git a/frontend/src/components/stat-tile.tsx b/frontend/src/components/stat-tile.tsx
new file mode 100644
index 0000000..98f6f93
--- /dev/null
+++ b/frontend/src/components/stat-tile.tsx
@@ -0,0 +1,96 @@
+import { Link } from "react-router-dom";
+
+import { cn } from "@/lib/utils";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/** Stat-tile contract (dataviz skill): label · value · optional delta ·
+ * optional context/detail rows. Values are pre-formatted by the caller
+ * (formatAmount etc.) and blur under privacy mode via data-sensitive. */
+export interface StatTileProps {
+ label: string;
+ /** Small line under the label, e.g. "Ciclo al 18 jul". */
+ description?: string;
+ /** Pre-formatted headline value. null renders an em dash. */
+ value: string | null;
+ loading?: boolean;
+ /** Signed, pre-formatted delta plus whether it's good news (colors it). */
+ delta?: { text: string; positive: boolean };
+ /** Muted line(s) or detail rows under the value. */
+ children?: React.ReactNode;
+ /** Makes the whole tile a link (adds hover border + focus ring). */
+ to?: string;
+ ariaLabel?: string;
+ /** Set false for non-monetary values that shouldn't blur (counts, dates). */
+ sensitive?: boolean;
+ className?: string;
+}
+
+export function StatTile({
+ label,
+ description,
+ value,
+ loading = false,
+ delta,
+ children,
+ to,
+ ariaLabel,
+ sensitive = true,
+ className,
+}: StatTileProps) {
+ const card = (
+
+
+
+ {label}
+
+ {description && (
+ {description}
+ )}
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {value ?? "—"}
+
+ )}
+ {!loading && delta && (
+
+ {delta.text}
+
+ )}
+
+ {children}
+
+
+ );
+
+ if (!to) return card;
+ return (
+
+ {card}
+
+ );
+}
diff --git a/frontend/src/components/ui/empty.tsx b/frontend/src/components/ui/empty.tsx
new file mode 100644
index 0000000..b23187b
--- /dev/null
+++ b/frontend/src/components/ui/empty.tsx
@@ -0,0 +1,104 @@
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+function Empty({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+const emptyMediaVariants = cva(
+ "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function EmptyMedia({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Empty,
+ EmptyHeader,
+ EmptyTitle,
+ EmptyDescription,
+ EmptyContent,
+ EmptyMedia,
+}
diff --git a/frontend/src/components/ui/field.tsx b/frontend/src/components/ui/field.tsx
new file mode 100644
index 0000000..654b9b4
--- /dev/null
+++ b/frontend/src/components/ui/field.tsx
@@ -0,0 +1,236 @@
+import { useMemo } from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+import { Separator } from "@/components/ui/separator"
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+