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 ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +