diff --git a/frontend/app/components/Dashboard.test.tsx b/frontend/app/components/Dashboard.test.tsx
index 92ee795..5950109 100644
--- a/frontend/app/components/Dashboard.test.tsx
+++ b/frontend/app/components/Dashboard.test.tsx
@@ -1,6 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import Dashboard from "./Dashboard";
+import type { Sale } from "../lib/dashboard";
// Stub the charts so the test doesn't depend on Recharts/ResponsiveContainer layout.
vi.mock("./ProductRevenueUnitsChart", () => ({
@@ -13,24 +14,33 @@ vi.mock("./ByCustomerChart", () => ({
{data.length}
),
}));
+vi.mock("./RevenueOverTimeChart", () => ({
+ default: ({ data }: { data: unknown[] }) => (
+ {data.length}
+ ),
+}));
+
+const sale = (productName: string, customerId: string): Sale => ({
+ date: "2026-01-01",
+ customerId,
+ productName,
+ quantity: 1,
+ amount: 10,
+});
describe("Dashboard", () => {
afterEach(() => {
vi.restoreAllMocks();
});
- it("renders the charts and never warms up when initial data is present", () => {
+ it("derives the charts from sales and never warms up when data is present", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
- render(
- ,
- );
+ render();
- expect(screen.getByTestId("by-product")).toHaveTextContent("1");
+ // Two distinct products / customers derived from the sales.
+ expect(screen.getByTestId("by-product")).toHaveTextContent("2");
+ expect(screen.getByTestId("by-customer")).toHaveTextContent("2");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -38,12 +48,10 @@ describe("Dashboard", () => {
it("shows a warming message and triggers a refresh when starting empty", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
- json: async () => ({ byProduct: [], byCustomer: [] }),
+ json: async () => ({ sales: [] }),
} as Response);
- render(
- ,
- );
+ render();
expect(await screen.findByRole("status")).toHaveTextContent(
/Calentando la demo/i,
diff --git a/frontend/app/components/Dashboard.tsx b/frontend/app/components/Dashboard.tsx
index c2fe277..83614ca 100644
--- a/frontend/app/components/Dashboard.tsx
+++ b/frontend/app/components/Dashboard.tsx
@@ -6,64 +6,64 @@ import ByCustomerChart from "./ByCustomerChart";
import RevenueOverTimeChart from "./RevenueOverTimeChart";
import KpiCards from "./KpiCards";
import ChartCard from "./ChartCard";
+import FilterBar from "./FilterBar";
import {
+ EMPTY_FILTERS,
computeKpis,
+ customerTotals,
+ dateRange,
+ filterSales,
+ hasActiveFilters,
productRevenueUnits,
+ productTotals,
revenueByDate,
salesCountByDate,
+ uniqueCustomers,
+ uniqueProducts,
+ type Filters,
} from "../lib/analytics";
-import type {
- CustomerTotal,
- DashboardData,
- ProductTotal,
- Sale,
-} from "../lib/dashboard";
-
-type Props = {
- initialByProduct: ProductTotal[];
- initialByCustomer: CustomerTotal[];
- initialSales: Sale[];
-};
+import type { DashboardData, Sale } from "../lib/dashboard";
+
+type Props = { initialSales: Sale[] };
const POLL_INTERVAL_MS = 5000;
const MAX_ATTEMPTS = 30; // ~2.5 min, covers a free-tier mock + backend cold start
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-// Wraps the dashboard and self-heals on a free-tier cold start: when we render with no data
-// (the store is empty until the backend re-seeds from the sleeping mock) we re-trigger a
-// refresh and poll until rows appear, instead of leaving a dead "No hay datos".
-export default function Dashboard({
- initialByProduct,
- initialByCustomer,
- initialSales,
-}: Props) {
- const initialEmpty =
- initialByProduct.length === 0 && initialByCustomer.length === 0;
-
- const [byProduct, setByProduct] = useState(initialByProduct);
- const [byCustomer, setByCustomer] = useState(initialByCustomer);
+// Owns the raw sales + filter state and derives every aggregate client-side, so the date
+// range and product/customer filters recompute the whole dashboard consistently. Also
+// self-heals on a free-tier cold start (re-trigger refresh + poll until rows appear).
+export default function Dashboard({ initialSales }: Props) {
+ const initialEmpty = initialSales.length === 0;
+
const [sales, setSales] = useState(initialSales);
+ const [filters, setFilters] = useState(EMPTY_FILTERS);
const [warming, setWarming] = useState(initialEmpty);
const [gaveUp, setGaveUp] = useState(false);
const running = useRef(false);
- const hasData = byProduct.length > 0 || byCustomer.length > 0;
+ const hasData = sales.length > 0;
+ const active = hasActiveFilters(filters);
+
+ const range = useMemo(() => dateRange(sales), [sales]);
+ const productOptions = useMemo(() => uniqueProducts(sales), [sales]);
+ const customerOptions = useMemo(() => uniqueCustomers(sales), [sales]);
+ const filtered = useMemo(() => filterSales(sales, filters), [sales, filters]);
+ const byProduct = useMemo(() => productTotals(filtered), [filtered]);
+ const byCustomer = useMemo(() => customerTotals(filtered), [filtered]);
const kpis = useMemo(
- () => computeKpis(sales, byProduct, byCustomer),
- [sales, byProduct, byCustomer],
+ () => computeKpis(filtered, byProduct, byCustomer),
+ [filtered, byProduct, byCustomer],
);
- const overTime = useMemo(() => revenueByDate(sales), [sales]);
- const salesCount = useMemo(() => salesCountByDate(sales), [sales]);
+ const overTime = useMemo(() => revenueByDate(filtered), [filtered]);
+ const salesCount = useMemo(() => salesCountByDate(filtered), [filtered]);
const productData = useMemo(
- () => productRevenueUnits(byProduct, sales),
- [byProduct, sales],
+ () => productRevenueUnits(byProduct, filtered),
+ [byProduct, filtered],
);
- // Re-triggers an ingestion each round, then checks for rows. On a cold free-tier stack the
- // first refresh 502s (the mock is asleep) but *wakes* it, so a later attempt succeeds — a
- // single refresh isn't enough. No synchronous state updates: every setState is post-await.
const poll = useCallback(async () => {
if (running.current) return;
running.current = true;
@@ -78,9 +78,7 @@ export default function Dashboard({
const res = await fetch("/api/dashboard", { cache: "no-store" });
if (res.ok) {
const data: DashboardData = await res.json();
- if (data.byProduct.length > 0 || data.byCustomer.length > 0) {
- setByProduct(data.byProduct);
- setByCustomer(data.byCustomer);
+ if (data.sales.length > 0) {
setSales(data.sales);
setWarming(false);
running.current = false;
@@ -98,12 +96,6 @@ export default function Dashboard({
running.current = false;
}, []);
- const retry = useCallback(() => {
- setGaveUp(false);
- setWarming(true);
- poll();
- }, [poll]);
-
useEffect(() => {
// Mount only: if SSR already delivered data we never warm up. Deferred so the async
// work starts outside the effect body (no synchronous state update on mount).
@@ -124,12 +116,33 @@ export default function Dashboard({
{gaveUp && !hasData && (
La demo sigue arrancando.{" "}
-
)}
+ {hasData && (
+ setFilters(EMPTY_FILTERS)}
+ range={range}
+ productOptions={productOptions}
+ customerOptions={customerOptions}
+ active={active}
+ />
+ )}
+
+ {hasData && filtered.length === 0 && (
+
+ No hay ventas para los filtros seleccionados.{" "}
+ setFilters(EMPTY_FILTERS)}>
+ Limpiar filtros
+
+
+ )}
+
d.total)}
diff --git a/frontend/app/components/FilterBar.tsx b/frontend/app/components/FilterBar.tsx
new file mode 100644
index 0000000..2247ed9
--- /dev/null
+++ b/frontend/app/components/FilterBar.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import type { Filters } from "../lib/analytics";
+import MultiSelect from "./MultiSelect";
+
+type Props = {
+ filters: Filters;
+ onChange: (next: Filters) => void;
+ onReset: () => void;
+ range: { min: string | null; max: string | null };
+ productOptions: string[];
+ customerOptions: string[];
+ active: boolean;
+};
+
+export default function FilterBar({
+ filters,
+ onChange,
+ onReset,
+ range,
+ productOptions,
+ customerOptions,
+ active,
+}: Props) {
+ return (
+
+ );
+}
diff --git a/frontend/app/components/MultiSelect.test.tsx b/frontend/app/components/MultiSelect.test.tsx
new file mode 100644
index 0000000..80514cf
--- /dev/null
+++ b/frontend/app/components/MultiSelect.test.tsx
@@ -0,0 +1,41 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import MultiSelect from "./MultiSelect";
+
+describe("MultiSelect", () => {
+ it("opens and toggles an option on", () => {
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /Productos/i }));
+ fireEvent.click(screen.getByLabelText("B"));
+
+ expect(onChange).toHaveBeenCalledWith(["B"]);
+ });
+
+ it("clears the selection", () => {
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /Clientes/i }));
+ fireEvent.click(screen.getByRole("button", { name: /Limpiar selección/i }));
+
+ expect(onChange).toHaveBeenCalledWith([]);
+ });
+});
diff --git a/frontend/app/components/MultiSelect.tsx b/frontend/app/components/MultiSelect.tsx
new file mode 100644
index 0000000..d399c41
--- /dev/null
+++ b/frontend/app/components/MultiSelect.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+type Props = {
+ label: string;
+ options: string[];
+ selected: string[];
+ onChange: (next: string[]) => void;
+};
+
+// Compact multi-select dropdown (button + popover with search + checkboxes). Empty
+// selection means "all". No dependencies; closes on outside click / Escape.
+export default function MultiSelect({ label, options, selected, onChange }: Props) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const ref = useRef(null);
+
+ 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);
+ };
+ document.addEventListener("pointerdown", onPointerDown);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("pointerdown", onPointerDown);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ const selectedSet = new Set(selected);
+ const filtered = query
+ ? options.filter((o) => o.toLowerCase().includes(query.toLowerCase()))
+ : options;
+
+ const toggle = (option: string) => {
+ const next = new Set(selectedSet);
+ if (next.has(option)) next.delete(option);
+ else next.add(option);
+ onChange([...next]);
+ };
+
+ return (
+
+
setOpen((v) => !v)}
+ >
+ {label}
+
+ {selected.length > 0 ? selected.length : "todos"}
+
+
+
+ {open && (
+
+
setQuery(e.target.value)}
+ autoFocus
+ />
+
+ {filtered.length === 0 && (
+
Sin coincidencias
+ )}
+ {filtered.map((option) => (
+
+ ))}
+
+ {selected.length > 0 && (
+
onChange([])}
+ >
+ Limpiar selección
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index f5b2060..8def2e0 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -209,3 +209,133 @@ button {
button:hover {
filter: brightness(1.05);
}
+
+/* Filter bar ------------------------------------------------------------- */
+.filter-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 1rem;
+}
+
+.filter-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+
+.filter-field__label {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.filter-input,
+.multiselect__button {
+ font: inherit;
+ color: var(--text);
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 0.45rem 0.7rem;
+}
+
+.filter-input {
+ color-scheme: light dark;
+}
+
+.filter-reset {
+ margin-left: auto;
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+}
+
+.filter-reset:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+/* Multi-select ----------------------------------------------------------- */
+.multiselect {
+ position: relative;
+}
+
+.multiselect__button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ min-width: 130px;
+ justify-content: space-between;
+}
+
+.multiselect__count {
+ font-size: 0.78rem;
+ color: var(--surface);
+ background: var(--accent);
+ border-radius: 999px;
+ padding: 0.05rem 0.5rem;
+}
+
+.multiselect__panel {
+ position: absolute;
+ z-index: 20;
+ top: calc(100% + 0.35rem);
+ left: 0;
+ width: 240px;
+ max-width: 80vw;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-md);
+ padding: 0.6rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.multiselect__search {
+ font: inherit;
+ color: var(--text);
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 0.4rem 0.6rem;
+}
+
+.multiselect__options {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ max-height: 220px;
+ overflow-y: auto;
+}
+
+.multiselect__option {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.3rem 0.35rem;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+
+.multiselect__option:hover {
+ background: var(--surface-2);
+}
+
+.multiselect__empty {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ padding: 0.3rem;
+}
+
+.multiselect__clear {
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+ font-size: 0.82rem;
+}
diff --git a/frontend/app/lib/analytics.test.ts b/frontend/app/lib/analytics.test.ts
index 79dcdcc..6d4320e 100644
--- a/frontend/app/lib/analytics.test.ts
+++ b/frontend/app/lib/analytics.test.ts
@@ -1,12 +1,31 @@
import { describe, expect, it } from "vitest";
import {
computeKpis,
+ customerTotals,
+ dateRange,
+ filterSales,
productRevenueUnits,
+ productTotals,
revenueByDate,
salesCountByDate,
+ uniqueProducts,
} from "./analytics";
import type { Sale } from "./dashboard";
+const S = (date: string, productName: string, customerId: string, amount: number): Sale => ({
+ date,
+ productName,
+ customerId,
+ quantity: 1,
+ amount,
+});
+
+const SAMPLE: Sale[] = [
+ S("2026-01-01", "A", "C1", 30),
+ S("2026-01-02", "B", "C2", 20),
+ S("2026-01-03", "A", "C2", 50),
+];
+
const sale = (date: string, amount: number, quantity = 1): Sale => ({
date,
amount,
@@ -116,3 +135,56 @@ describe("productRevenueUnits", () => {
]);
});
});
+
+describe("filterSales", () => {
+ it("filters by inclusive date range", () => {
+ const result = filterSales(SAMPLE, {
+ from: "2026-01-02",
+ to: "2026-01-02",
+ products: [],
+ customers: [],
+ });
+ expect(result).toHaveLength(1);
+ expect(result[0].productName).toBe("B");
+ });
+
+ it("filters by selected products and customers", () => {
+ const result = filterSales(SAMPLE, {
+ from: null,
+ to: null,
+ products: ["A"],
+ customers: ["C2"],
+ });
+ expect(result).toEqual([S("2026-01-03", "A", "C2", 50)]);
+ });
+
+ it("returns all sales when no filter is set", () => {
+ expect(
+ filterSales(SAMPLE, { from: null, to: null, products: [], customers: [] }),
+ ).toHaveLength(3);
+ });
+});
+
+describe("productTotals / customerTotals", () => {
+ it("groups and sorts by amount desc", () => {
+ expect(productTotals(SAMPLE)).toEqual([
+ { product: "A", totalAmount: 80 },
+ { product: "B", totalAmount: 20 },
+ ]);
+ expect(customerTotals(SAMPLE)).toEqual([
+ { customerId: "C2", totalAmount: 70 },
+ { customerId: "C1", totalAmount: 30 },
+ ]);
+ });
+});
+
+describe("dateRange / uniqueProducts", () => {
+ it("returns the min and max dates and the sorted distinct products", () => {
+ expect(dateRange(SAMPLE)).toEqual({ min: "2026-01-01", max: "2026-01-03" });
+ expect(uniqueProducts(SAMPLE)).toEqual(["A", "B"]);
+ });
+
+ it("handles an empty dataset", () => {
+ expect(dateRange([])).toEqual({ min: null, max: null });
+ });
+});
diff --git a/frontend/app/lib/analytics.ts b/frontend/app/lib/analytics.ts
index 72a0ca3..1cfa1a0 100644
--- a/frontend/app/lib/analytics.ts
+++ b/frontend/app/lib/analytics.ts
@@ -91,3 +91,81 @@ export function computeKpis(
bestDayTotal: daily.length > 0 ? best.total : 0,
};
}
+
+// --- Filtering & client-side aggregates -----------------------------------
+// With filters, the backend's precomputed by-product/by-customer would be stale, so the
+// dashboard derives every aggregate from the (filtered) raw sales instead.
+
+export type Filters = {
+ from: string | null; // inclusive ISO date, null = no lower bound
+ to: string | null; // inclusive ISO date, null = no upper bound
+ products: string[]; // empty = all products
+ customers: string[]; // empty = all customers
+};
+
+export const EMPTY_FILTERS: Filters = {
+ from: null,
+ to: null,
+ products: [],
+ customers: [],
+};
+
+// ISO YYYY-MM-DD compares lexicographically the same as chronologically.
+export function filterSales(sales: Sale[], filters: Filters): Sale[] {
+ const products = filters.products.length > 0 ? new Set(filters.products) : null;
+ const customers = filters.customers.length > 0 ? new Set(filters.customers) : null;
+ return sales.filter(
+ (s) =>
+ (!filters.from || s.date >= filters.from) &&
+ (!filters.to || s.date <= filters.to) &&
+ (!products || products.has(s.productName)) &&
+ (!customers || customers.has(s.customerId)),
+ );
+}
+
+export function hasActiveFilters(filters: Filters): boolean {
+ return (
+ filters.from !== null ||
+ filters.to !== null ||
+ filters.products.length > 0 ||
+ filters.customers.length > 0
+ );
+}
+
+export function dateRange(sales: Sale[]): { min: string | null; max: string | null } {
+ if (sales.length === 0) return { min: null, max: null };
+ let min = sales[0].date;
+ let max = sales[0].date;
+ for (const s of sales) {
+ if (s.date < min) min = s.date;
+ if (s.date > max) max = s.date;
+ }
+ return { min, max };
+}
+
+export const uniqueProducts = (sales: Sale[]): string[] =>
+ [...new Set(sales.map((s) => s.productName))].sort((a, b) => a.localeCompare(b));
+
+export const uniqueCustomers = (sales: Sale[]): string[] =>
+ [...new Set(sales.map((s) => s.customerId))].sort((a, b) => a.localeCompare(b));
+
+// Client-side equivalents of the backend aggregates, sorted by amount desc.
+export function productTotals(sales: Sale[]): ProductTotal[] {
+ const totals = new Map();
+ for (const s of sales) {
+ totals.set(s.productName, (totals.get(s.productName) ?? 0) + s.amount);
+ }
+ return [...totals.entries()]
+ .map(([product, totalAmount]) => ({ product, totalAmount }))
+ .sort((a, b) => b.totalAmount - a.totalAmount);
+}
+
+export function customerTotals(sales: Sale[]): CustomerTotal[] {
+ const totals = new Map();
+ for (const s of sales) {
+ totals.set(s.customerId, (totals.get(s.customerId) ?? 0) + s.amount);
+ }
+ return [...totals.entries()]
+ .map(([customerId, totalAmount]) => ({ customerId, totalAmount }))
+ .sort((a, b) => b.totalAmount - a.totalAmount);
+}
diff --git a/frontend/app/lib/dashboard.ts b/frontend/app/lib/dashboard.ts
index 409ffb8..16a7ea0 100644
--- a/frontend/app/lib/dashboard.ts
+++ b/frontend/app/lib/dashboard.ts
@@ -7,13 +7,13 @@ export type Sale = {
quantity: number;
amount: number;
};
+// The dashboard derives every aggregate client-side from the raw sales (so filters can
+// recompute them), so a single fetch of the raw sales is all it needs.
export type DashboardData = {
- byProduct: ProductTotal[];
- byCustomer: CustomerTotal[];
sales: Sale[];
};
-const EMPTY: DashboardData = { byProduct: [], byCustomer: [], sales: [] };
+const EMPTY: DashboardData = { sales: [] };
const backendUrl = () => process.env.BACKEND_URL ?? "http://localhost:5080";
@@ -24,19 +24,12 @@ export async function fetchDashboard(timeoutMs = 6000): Promise {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
- const base = backendUrl();
- const [product, customer, sales] = await Promise.all([
- fetch(`${base}/api/sales/by-product`, { cache: "no-store", signal: controller.signal }),
- fetch(`${base}/api/sales/by-customer`, { cache: "no-store", signal: controller.signal }),
- fetch(`${base}/api/sales`, { cache: "no-store", signal: controller.signal }),
- ]);
- if (!product.ok || !customer.ok) return EMPTY;
- return {
- byProduct: await product.json(),
- byCustomer: await customer.json(),
- // Raw sales power the time series + KPIs; tolerate it failing on its own.
- sales: sales.ok ? await sales.json() : [],
- };
+ const res = await fetch(`${backendUrl()}/api/sales`, {
+ cache: "no-store",
+ signal: controller.signal,
+ });
+ if (!res.ok) return EMPTY;
+ return { sales: await res.json() };
} catch {
return EMPTY;
} finally {
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index e828c78..bee558f 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -4,7 +4,7 @@ import { fetchDashboard } from "./lib/dashboard";
export default async function Page() {
// Best-effort initial fetch: instant charts when the backend is warm, empty (→ client
// self-heal) when it is cold. Never throws, so the demo never lands on the error boundary.
- const { byProduct, byCustomer, sales } = await fetchDashboard();
+ const { sales } = await fetchDashboard();
return (
@@ -13,11 +13,7 @@ export default async function Page() {
Prototype with simulated sales data.
-
+
);
}