Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 26 additions & 24 deletions frontend/app/components/ByCustomerChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,29 +26,31 @@ export default function ByCustomerChart({ data }: Props) {
);
}
return (
<ResponsiveContainer width="100%" height={320}>
<PieChart>
<Pie
data={data}
dataKey="totalAmount"
nameKey="customerId"
innerRadius={68}
outerRadius={108}
paddingAngle={2}
stroke={theme.surface}
strokeWidth={2}
isAnimationActive={false}
>
{data.map((entry, index) => (
<Cell
key={entry.customerId}
fill={theme.series[index % theme.series.length]}
/>
))}
</Pie>
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12, color: theme.axis }} />
</PieChart>
</ResponsiveContainer>
<div role="img" aria-label="Gráfico de donut: importe total por cliente">
<ResponsiveContainer width="100%" height={320}>
<PieChart>
<Pie
data={data}
dataKey="totalAmount"
nameKey="customerId"
innerRadius={68}
outerRadius={108}
paddingAngle={2}
stroke={theme.surface}
strokeWidth={2}
isAnimationActive={false}
>
{data.map((entry, index) => (
<Cell
key={entry.customerId}
fill={theme.series[index % theme.series.length]}
/>
))}
</Pie>
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12, color: theme.axis }} />
</PieChart>
</ResponsiveContainer>
</div>
);
}
39 changes: 39 additions & 0 deletions frontend/app/components/ChartTooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import ChartTooltip from "./ChartTooltip";

describe("ChartTooltip", () => {
it("renders nothing when inactive", () => {
const { container } = render(
<ChartTooltip active={false} payload={[{ name: "x", value: 1 }]} />,
);
expect(container).toBeEmptyDOMElement();
});

it("formats a single-series value with the label", () => {
render(
<ChartTooltip
active
label="Café Molido"
payload={[{ name: "Ingresos", value: 1234.5 }]}
/>,
);
expect(screen.getByText("Café Molido")).toBeInTheDocument();
expect(screen.getByText("1,234.5")).toBeInTheDocument();
});

it("lists each series for a multi-series payload", () => {
render(
<ChartTooltip
active
label="A"
payload={[
{ name: "Ingresos", value: 100 },
{ name: "Unidades", value: 5 },
]}
/>,
);
expect(screen.getByText(/Ingresos:/)).toBeInTheDocument();
expect(screen.getByText(/Unidades:/)).toBeInTheDocument();
});
});
14 changes: 13 additions & 1 deletion frontend/app/components/Dashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import Dashboard from "./Dashboard";
import type { Sale } from "../lib/dashboard";
Expand Down Expand Up @@ -34,4 +34,16 @@ describe("Dashboard", () => {
expect(screen.getByTestId("by-product")).toHaveTextContent("2");
expect(screen.getByTestId("by-customer")).toHaveTextContent("2");
});

it("shows the empty-filter state when the date range excludes all sales", () => {
render(<Dashboard sales={[sale("A", "C1")]} />);

fireEvent.change(screen.getByLabelText("Desde"), {
target: { value: "2027-01-01" },
});

expect(
screen.getByText(/No hay ventas para los filtros seleccionados/i),
).toBeInTheDocument();
});
});
6 changes: 3 additions & 3 deletions frontend/app/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,15 @@ export default function Dashboard({ sales }: Props) {
salesTrend={salesCount.map((d) => d.count)}
/>

<ChartCard title="Revenue over time">
<ChartCard title="Ingresos en el tiempo">
<RevenueOverTimeChart data={overTime} />
</ChartCard>

<div className="chart-grid">
<ChartCard title="Revenue & units by product">
<ChartCard title="Ingresos y unidades por producto">
<ProductRevenueUnitsChart data={productData} />
</ChartCard>
<ChartCard title="Total amount by customer">
<ChartCard title="Importe total por cliente">
<ByCustomerChart data={byCustomer} />
</ChartCard>
</div>
Expand Down
47 changes: 47 additions & 0 deletions frontend/app/components/FilterBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import FilterBar from "./FilterBar";
import { EMPTY_FILTERS } from "../lib/analytics";

const baseProps = {
filters: EMPTY_FILTERS,
range: { min: "2026-01-01", max: "2026-01-31" },
productOptions: ["A", "B"],
customerOptions: ["C1", "C2"],
};

describe("FilterBar", () => {
it("disables the reset button when there are no active filters", () => {
render(
<FilterBar
{...baseProps}
active={false}
onChange={vi.fn()}
onReset={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /Limpiar filtros/i })).toBeDisabled();
});

it("calls onReset when active and the reset button is clicked", () => {
const onReset = vi.fn();
render(
<FilterBar {...baseProps} active onChange={vi.fn()} onReset={onReset} />,
);
fireEvent.click(screen.getByRole("button", { name: /Limpiar filtros/i }));
expect(onReset).toHaveBeenCalled();
});

it("propagates a date-range change via onChange", () => {
const onChange = vi.fn();
render(
<FilterBar {...baseProps} active={false} onChange={onChange} onReset={vi.fn()} />,
);
fireEvent.change(screen.getByLabelText("Desde"), {
target: { value: "2026-01-10" },
});
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ from: "2026-01-10" }),
);
});
});
6 changes: 3 additions & 3 deletions frontend/app/components/KpiCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ describe("KpiCards", () => {
/>,
);

expect(screen.getByText("Total revenue")).toBeInTheDocument();
expect(screen.getByText("Ingresos totales")).toBeInTheDocument();
expect(screen.getByText("1,234.5")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByText("Customers")).toBeInTheDocument();
expect(screen.getByText("Products")).toBeInTheDocument();
expect(screen.getByText("Clientes")).toBeInTheDocument();
expect(screen.getByText("Productos")).toBeInTheDocument();
});
});
16 changes: 8 additions & 8 deletions frontend/app/components/KpiCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,30 @@ type Item = {
export default function KpiCards({ kpis, revenueTrend, salesTrend }: Props) {
const items: Item[] = [
{
label: "Total revenue",
label: "Ingresos totales",
value: formatAmountFull(kpis.totalRevenue),
trend: revenueTrend,
color: "var(--chart-1)",
},
{
label: "Sales",
label: "Ventas",
value: formatInt(kpis.transactions),
trend: salesTrend,
color: "var(--chart-2)",
},
{ label: "Avg ticket", value: formatAmountFull(kpis.avgTicket) },
{ label: "Units sold", value: formatInt(kpis.totalUnits) },
{ label: "Customers", value: formatInt(kpis.distinctCustomers) },
{ label: "Products", value: formatInt(kpis.distinctProducts) },
{ label: "Ticket medio", value: formatAmountFull(kpis.avgTicket) },
{ label: "Unidades", value: formatInt(kpis.totalUnits) },
{ label: "Clientes", value: formatInt(kpis.distinctCustomers) },
{ label: "Productos", value: formatInt(kpis.distinctProducts) },
{
label: "Best day",
label: "Mejor día",
value: kpis.bestDayDate ? formatAmountFull(kpis.bestDayTotal) : "—",
sub: kpis.bestDayDate ? formatDateShort(kpis.bestDayDate) : undefined,
},
];

return (
<section className="kpi-grid" aria-label="Key metrics">
<section className="kpi-grid" aria-label="Métricas clave">
{items.map((item) => (
<div className="kpi card" key={item.label}>
<span className="kpi__value">{item.value}</span>
Expand Down
16 changes: 13 additions & 3 deletions frontend/app/components/MultiSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";

type Props = {
label: string;
Expand All @@ -13,14 +13,21 @@ export default function MultiSelect({ label, options, selected, onChange }: Prop
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const panelId = useId();

const close = () => {
setOpen(false);
buttonRef.current?.focus();
};

useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
if (event.key === "Escape") close();
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKey);
Expand All @@ -45,9 +52,12 @@ export default function MultiSelect({ label, options, selected, onChange }: Prop
return (
<div className="multiselect" ref={ref}>
<button
ref={buttonRef}
type="button"
className="multiselect__button"
aria-haspopup="true"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
>
{label}
Expand All @@ -57,7 +67,7 @@ export default function MultiSelect({ label, options, selected, onChange }: Prop
</button>

{open && (
<div className="multiselect__panel" role="listbox" aria-label={label}>
<div className="multiselect__panel" id={panelId} aria-label={label}>
<input
type="search"
className="multiselect__search"
Expand Down
100 changes: 51 additions & 49 deletions frontend/app/components/ProductRevenueUnitsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,54 +30,56 @@ export default function ProductRevenueUnitsChart({ data }: Props) {
}

return (
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={data} margin={{ top: 10, right: 8, bottom: 28, left: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={theme.grid} vertical={false} />
<XAxis
dataKey="product"
tick={{ fill: theme.axis, fontSize: 12 }}
stroke={theme.border}
interval={0}
angle={-15}
textAnchor="end"
height={56}
/>
<YAxis
yAxisId="revenue"
tickFormatter={formatAmountCompact}
tick={{ fill: theme.axis, fontSize: 12 }}
stroke={theme.border}
width={48}
/>
<YAxis
yAxisId="units"
orientation="right"
tickFormatter={formatInt}
tick={{ fill: theme.axis, fontSize: 12 }}
stroke={theme.border}
width={40}
/>
<Tooltip cursor={{ fill: theme.cursor }} content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12, color: theme.axis }} />
<Bar
yAxisId="revenue"
dataKey="revenue"
name="Revenue"
fill={theme.series[1]}
radius={[6, 6, 0, 0]}
maxBarSize={48}
isAnimationActive={false}
/>
<Line
yAxisId="units"
dataKey="units"
name="Units"
stroke={theme.series[3]}
strokeWidth={2}
dot={{ r: 3 }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
<div role="img" aria-label="Gráfico de barras y línea: ingresos y unidades por producto">
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={data} margin={{ top: 10, right: 8, bottom: 28, left: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={theme.grid} vertical={false} />
<XAxis
dataKey="product"
tick={{ fill: theme.axis, fontSize: 12 }}
stroke={theme.border}
interval={0}
angle={-15}
textAnchor="end"
height={56}
/>
<YAxis
yAxisId="revenue"
tickFormatter={formatAmountCompact}
tick={{ fill: theme.axis, fontSize: 12 }}
stroke={theme.border}
width={48}
/>
<YAxis
yAxisId="units"
orientation="right"
tickFormatter={formatInt}
tick={{ fill: theme.axis, fontSize: 12 }}
stroke={theme.border}
width={40}
/>
<Tooltip cursor={{ fill: theme.cursor }} content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12, color: theme.axis }} />
<Bar
yAxisId="revenue"
dataKey="revenue"
name="Ingresos"
fill={theme.series[1]}
radius={[6, 6, 0, 0]}
maxBarSize={48}
isAnimationActive={false}
/>
<Line
yAxisId="units"
dataKey="units"
name="Unidades"
stroke={theme.series[3]}
strokeWidth={2}
dot={{ r: 3 }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
Loading
Loading