From d2c33f965a778cdc76b98b25c6d06c2a849a2c99 Mon Sep 17 00:00:00 2001 From: Aitor Reviriego Amor Date: Tue, 2 Jun 2026 00:12:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20filtros=20configurables=20(fe?= =?UTF-8?q?chas=20+=20producto/cliente)=20con=20rec=C3=A1lculo=20en=20clie?= =?UTF-8?q?nte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El dashboard pasa a derivar TODOS los agregados en cliente desde las ventas crudas, de modo que los filtros recalculan KPIs y los 4 gráficos a la vez de forma consistente. Sin tocar el backend. - FilterBar: rango de fechas (acotado al min/max de los datos) + multi-select de productos y clientes + 'Limpiar filtros'. - MultiSelect: dropdown propio con búsqueda, checkboxes y cierre por click-fuera/ Escape; sin dependencias nuevas. - analytics.ts: filterSales, productTotals, customerTotals, dateRange, uniqueProducts/Customers, hasActiveFilters, EMPTY_FILTERS (funciones puras). - fetchDashboard ahora trae solo /api/sales (1 request en vez de 3); el dashboard deriva by-product/by-customer del set filtrado. - Estado de 'sin resultados para los filtros' con reset. Self-heal de cold start intacto (ahora basado en sales.length). Co-Authored-By: Claude Opus 4.8 --- frontend/app/components/Dashboard.test.tsx | 34 +++-- frontend/app/components/Dashboard.tsx | 103 ++++++++------- frontend/app/components/FilterBar.tsx | 87 +++++++++++++ frontend/app/components/MultiSelect.test.tsx | 41 ++++++ frontend/app/components/MultiSelect.tsx | 99 ++++++++++++++ frontend/app/globals.css | 130 +++++++++++++++++++ frontend/app/lib/analytics.test.ts | 72 ++++++++++ frontend/app/lib/analytics.ts | 78 +++++++++++ frontend/app/lib/dashboard.ts | 25 ++-- frontend/app/page.tsx | 8 +- 10 files changed, 597 insertions(+), 80 deletions(-) create mode 100644 frontend/app/components/FilterBar.tsx create mode 100644 frontend/app/components/MultiSelect.test.tsx create mode 100644 frontend/app/components/MultiSelect.tsx 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.{" "} + +

+ )} + 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 ( +
+
+ + onChange({ ...filters, from: e.target.value || null })} + /> +
+ +
+ + onChange({ ...filters, to: e.target.value || null })} + /> +
+ +
+ Productos + onChange({ ...filters, products })} + /> +
+ +
+ Clientes + onChange({ ...filters, customers })} + /> +
+ + +
+ ); +} 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 ( +
+ + + {open && ( +
+ setQuery(e.target.value)} + autoFocus + /> +
+ {filtered.length === 0 && ( +

Sin coincidencias

+ )} + {filtered.map((option) => ( + + ))} +
+ {selected.length > 0 && ( + + )} +
+ )} +
+ ); +} 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.

- +
); }